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

531 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -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-1819: Regex patterns for detecting multimodal evaluation in prompt actions 

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

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

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

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

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

113) 

114 

115# Valid comparison operators 

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

117 

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

119KNOWN_TOP_LEVEL_KEYS: frozenset[str] = frozenset( 

120 { 

121 "name", 

122 "description", 

123 "initial", 

124 "states", 

125 "context", 

126 "parameters", 

127 "scope", 

128 "max_iterations", 

129 "on_max_iterations", 

130 "max_edge_revisits", 

131 "backoff", 

132 "timeout", 

133 "default_timeout", 

134 "maintain", 

135 "llm", 

136 "on_handoff", 

137 "input_key", 

138 "required_inputs", 

139 "config", 

140 "category", 

141 "labels", 

142 "commands", 

143 "targets", 

144 "circuit", 

145 "meta_self_eval_ok", 

146 "shared_state_ok", 

147 "partial_route_ok", 

148 "import", 

149 "fragments", 

150 "from", 

151 "flow", 

152 "state_defs", 

153 } 

154) 

155 

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

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

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

159 

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

161VALID_PARAMETER_TYPES: frozenset[str] = frozenset( 

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

163) 

164 

165 

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

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

168 

169 Args: 

170 state_name: Name of the state containing this evaluator 

171 evaluate: The evaluator configuration to validate 

172 

173 Returns: 

174 List of validation errors found 

175 """ 

176 errors: list[ValidationError] = [] 

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

178 

179 # Check that evaluator type is recognized 

180 valid_types = set(EVALUATOR_REQUIRED_FIELDS.keys()) 

181 if evaluate.type not in valid_types: 

182 errors.append( 

183 ValidationError( 

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

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

186 path=path, 

187 ) 

188 ) 

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

190 

191 # Check required fields for evaluator type 

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

193 for field_name in required: 

194 value = getattr(evaluate, field_name, None) 

195 if value is None: 

196 errors.append( 

197 ValidationError( 

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

199 path=path, 

200 ) 

201 ) 

202 

203 # Validate operator if present 

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

205 errors.append( 

206 ValidationError( 

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

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

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

210 ) 

211 ) 

212 

213 # Validate convergence-specific fields 

214 if evaluate.type == "convergence": 

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

216 errors.append( 

217 ValidationError( 

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

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

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

221 ) 

222 ) 

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

224 if ( 

225 evaluate.tolerance is not None 

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

227 and evaluate.tolerance < 0 

228 ): 

229 errors.append( 

230 ValidationError( 

231 message="Tolerance cannot be negative", 

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

233 ) 

234 ) 

235 

236 # Validate llm_structured-specific fields 

237 if evaluate.type == "llm_structured": 

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

239 errors.append( 

240 ValidationError( 

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

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

243 ) 

244 ) 

245 

246 # Validate diff_stall-specific fields 

247 if evaluate.type == "diff_stall": 

248 if evaluate.max_stall < 1: 

249 errors.append( 

250 ValidationError( 

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

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

253 ) 

254 ) 

255 

256 # Validate action_stall-specific fields 

257 if evaluate.type == "action_stall": 

258 if evaluate.max_repeat < 1: 

259 errors.append( 

260 ValidationError( 

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

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

263 ) 

264 ) 

265 

266 return errors 

267 

268 

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

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

271 

272 Args: 

273 fsm: The FSM loop to validate 

274 

275 Returns: 

276 List of validation errors found 

277 """ 

278 errors: list[ValidationError] = [] 

279 

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

281 path = f"parameters.{param_name}" 

282 

283 if param_spec.type not in VALID_PARAMETER_TYPES: 

284 errors.append( 

285 ValidationError( 

286 message=( 

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

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

289 ), 

290 path=path, 

291 ) 

292 ) 

293 

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

295 errors.append( 

296 ValidationError( 

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

298 path=path, 

299 ) 

300 ) 

301 

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

303 errors.append( 

304 ValidationError( 

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

306 path=path, 

307 ) 

308 ) 

309 

310 return errors 

311 

312 

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

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

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

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

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

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

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

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

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

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

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

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

325 return None 

326 

327 

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

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

330 

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

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

333 

334 Args: 

335 fsm: The parent FSM loop 

336 loop_dir: Directory to resolve child loop paths from 

337 

338 Returns: 

339 List of validation errors found 

340 """ 

341 errors: list[ValidationError] = [] 

342 

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

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

345 continue 

346 

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

348 try: 

349 from little_loops.cli.loop._helpers import resolve_loop_path 

350 

351 loop_path = resolve_loop_path(state.loop, loop_dir) 

352 child_fsm, _ = load_and_validate(loop_path) 

353 except Exception: 

354 continue 

355 

356 if not child_fsm.parameters: 

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

358 

359 path = f"states.{state_name}" 

360 

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

362 for key in state.with_: 

363 if key not in child_fsm.parameters: 

364 errors.append( 

365 ValidationError( 

366 message=( 

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

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

369 ), 

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

371 ) 

372 ) 

373 

374 # Required parameters not bound 

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

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

377 errors.append( 

378 ValidationError( 

379 message=( 

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

381 f"is not bound in 'with'" 

382 ), 

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

384 ) 

385 ) 

386 

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

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

389 if param_name not in child_fsm.parameters: 

390 continue 

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

392 continue 

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

394 if type_error: 

395 errors.append( 

396 ValidationError( 

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

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

399 ) 

400 ) 

401 

402 return errors 

403 

404 

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

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

407 

408 Called from load_and_validate (not validate_fsm) because fragment parameters 

409 are populated by resolve_fragments which runs before dataclass parsing. 

410 

411 Args: 

412 fsm: The FSM loop to validate 

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

414 _validate_with_bindings) 

415 

416 Returns: 

417 List of validation errors found 

418 """ 

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

420 RUNNER_INJECTED = {"run_dir", "loop_name", "started_at"} 

421 

422 errors: list[ValidationError] = [] 

423 

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

425 if not state.fragment_parameters: 

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

427 

428 path = f"states.{state_name}" 

429 

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

431 for key in state.fragment_bindings: 

432 if key not in state.fragment_parameters: 

433 errors.append( 

434 ValidationError( 

435 message=( 

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

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

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

439 ), 

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

441 ) 

442 ) 

443 

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

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

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

447 if param_name in RUNNER_INJECTED: 

448 continue # Available at runtime; not a static error 

449 errors.append( 

450 ValidationError( 

451 message=( 

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

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

454 ), 

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

456 ) 

457 ) 

458 

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

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

461 if param_name not in state.fragment_parameters: 

462 continue 

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

464 continue 

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

466 if type_error: 

467 errors.append( 

468 ValidationError( 

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

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

471 ) 

472 ) 

473 

474 return errors 

475 

476 

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

478 """Validate state action configuration. 

479 

480 Args: 

481 state_name: Name of the state to validate 

482 state: The state configuration to validate 

483 

484 Returns: 

485 List of validation errors found 

486 """ 

487 errors: list[ValidationError] = [] 

488 path = f"states.{state_name}" 

489 

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

491 if state.append_to_messages is not None: 

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

493 errors.append( 

494 ValidationError( 

495 message=( 

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

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

498 ), 

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

500 ) 

501 ) 

502 

503 # params field is only valid for mcp_tool states 

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

505 errors.append( 

506 ValidationError( 

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

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

509 ) 

510 ) 

511 

512 # loop and action are mutually exclusive 

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

514 errors.append( 

515 ValidationError( 

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

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

518 path=f"{path}", 

519 ) 

520 ) 

521 

522 # with: requires loop: to be set 

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

524 errors.append( 

525 ValidationError( 

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

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

528 ) 

529 ) 

530 

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

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

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

534 errors.append( 

535 ValidationError( 

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

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

538 ) 

539 ) 

540 if state.learning.max_retries < 0: 

541 errors.append( 

542 ValidationError( 

543 message=( 

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

545 ), 

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

547 ) 

548 ) 

549 if state.on_yes is None: 

550 errors.append( 

551 ValidationError( 

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

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

554 ) 

555 ) 

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

557 errors.append( 

558 ValidationError( 

559 message=( 

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

561 "(target for refuted / retries_exhausted)" 

562 ), 

563 path=f"{path}", 

564 ) 

565 ) 

566 

567 # with: and context_passthrough are mutually exclusive 

568 if state.with_ and state.context_passthrough: 

569 errors.append( 

570 ValidationError( 

571 message=( 

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

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

574 "for legacy bulk passthrough, not both" 

575 ), 

576 path=f"{path}", 

577 ) 

578 ) 

579 

580 return errors 

581 

582 

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

584 """Validate state routing configuration. 

585 

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

587 

588 Args: 

589 state_name: Name of the state to validate 

590 state: The state configuration to validate 

591 

592 Returns: 

593 List of validation errors/warnings found 

594 """ 

595 errors: list[ValidationError] = [] 

596 path = f"states.{state_name}" 

597 

598 has_shorthand = ( 

599 state.on_yes is not None 

600 or state.on_no is not None 

601 or state.on_error is not None 

602 or state.on_partial is not None 

603 or state.on_blocked is not None 

604 or bool(state.extra_routes) 

605 ) 

606 has_route = state.route is not None 

607 

608 # Warn about conflicting definitions 

609 if has_shorthand and has_route: 

610 errors.append( 

611 ValidationError( 

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

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

614 path=path, 

615 severity=ValidationSeverity.WARNING, 

616 ) 

617 ) 

618 

619 # Check for no valid transition definition 

620 has_next = state.next is not None 

621 has_terminal = state.terminal 

622 has_loop = state.loop is not None 

623 

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

625 errors.append( 

626 ValidationError( 

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

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

629 path=path, 

630 ) 

631 ) 

632 

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

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

635 errors.append( 

636 ValidationError( 

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

638 path=path, 

639 ) 

640 ) 

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

642 errors.append( 

643 ValidationError( 

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

645 path=path, 

646 ) 

647 ) 

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

649 errors.append( 

650 ValidationError( 

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

652 path=path, 

653 ) 

654 ) 

655 

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

657 if state.retryable_exit_codes is not None: 

658 if state.on_error is None: 

659 errors.append( 

660 ValidationError( 

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

662 path=path, 

663 ) 

664 ) 

665 for code in state.retryable_exit_codes: 

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

667 errors.append( 

668 ValidationError( 

669 message=( 

670 f"'retryable_exit_codes' entries must be positive " 

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

672 ), 

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

674 ) 

675 ) 

676 break 

677 

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

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

680 errors.append( 

681 ValidationError( 

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

683 path=path, 

684 ) 

685 ) 

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

687 errors.append( 

688 ValidationError( 

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

690 path=path, 

691 ) 

692 ) 

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

694 errors.append( 

695 ValidationError( 

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

697 path=path, 

698 ) 

699 ) 

700 if ( 

701 state.rate_limit_backoff_base_seconds is not None 

702 and state.rate_limit_backoff_base_seconds < 1 

703 ): 

704 errors.append( 

705 ValidationError( 

706 message=( 

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

708 f"got {state.rate_limit_backoff_base_seconds}" 

709 ), 

710 path=path, 

711 ) 

712 ) 

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

714 errors.append( 

715 ValidationError( 

716 message=( 

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

718 f"got {state.rate_limit_max_wait_seconds}" 

719 ), 

720 path=path, 

721 ) 

722 ) 

723 if state.rate_limit_long_wait_ladder is not None: 

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

725 errors.append( 

726 ValidationError( 

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

728 path=path, 

729 ) 

730 ) 

731 else: 

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

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

734 errors.append( 

735 ValidationError( 

736 message=( 

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

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

739 ), 

740 path=path, 

741 ) 

742 ) 

743 

744 # Validate throttle config when present 

745 if state.throttle is not None: 

746 t = state.throttle 

747 fields = { 

748 "normal_max": t.normal_max, 

749 "warn_max": t.warn_max, 

750 "hard_max": t.hard_max, 

751 } 

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

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

754 errors.append( 

755 ValidationError( 

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

757 path=path, 

758 ) 

759 ) 

760 # Enforce ordering when all three are set 

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

762 errors.append( 

763 ValidationError( 

764 message=( 

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

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

767 ), 

768 path=path, 

769 ) 

770 ) 

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

772 errors.append( 

773 ValidationError( 

774 message=( 

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

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

777 ), 

778 path=path, 

779 ) 

780 ) 

781 

782 return errors 

783 

784 

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

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

787 

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

789 end with a .yaml extension. 

790 """ 

791 errors: list[ValidationError] = [] 

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

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

794 errors.append( 

795 ValidationError( 

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

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

798 ) 

799 ) 

800 return errors 

801 

802 

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

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

805 

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

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

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

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

810 itself can execute. 

811 

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

813 failure terminals continue to load, and test_terminal_only_state_valid 

814 (which filters by ERROR) passes without modification. 

815 """ 

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

817 errors: list[ValidationError] = [] 

818 

819 terminal_states = fsm.get_terminal_states() 

820 failure_terminals = terminal_states & FAILURE_TERMINAL_NAMES 

821 

822 for ft_name in failure_terminals: 

823 has_diagnostic_predecessor = False 

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

825 if state_name == ft_name: 

826 continue 

827 if ft_name in state.get_referenced_states(): 

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

829 has_diagnostic_predecessor = True 

830 break 

831 

832 if not has_diagnostic_predecessor: 

833 errors.append( 

834 ValidationError( 

835 message=( 

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

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

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

839 f"to '{ft_name}'." 

840 ), 

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

842 severity=ValidationSeverity.WARNING, 

843 ) 

844 ) 

845 

846 return errors 

847 

848 

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

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

851 

852 Performs comprehensive validation: 

853 - Initial state exists 

854 - All referenced states exist 

855 - At least one terminal state 

856 - Evaluator configurations are valid 

857 - Routing configurations are valid 

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

859 

860 Args: 

861 fsm: The FSM loop to validate 

862 

863 Returns: 

864 List of validation errors (empty if valid) 

865 """ 

866 errors: list[ValidationError] = [] 

867 defined_states = fsm.get_all_state_names() 

868 

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

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

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

872 if not fsm.description: 

873 errors.append( 

874 ValidationError( 

875 path="<root>", 

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

877 severity=ValidationSeverity.WARNING, 

878 ) 

879 ) 

880 

881 # Validate parameters block 

882 errors.extend(_validate_parameters(fsm)) 

883 

884 # Validate targets block (ENH-1552) 

885 errors.extend(_validate_targets(fsm)) 

886 

887 # Check initial state exists 

888 if fsm.initial not in defined_states: 

889 errors.append( 

890 ValidationError( 

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

892 path="initial", 

893 ) 

894 ) 

895 

896 # Check at least one terminal state 

897 terminal_states = fsm.get_terminal_states() 

898 if not terminal_states: 

899 errors.append( 

900 ValidationError( 

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

902 path="states", 

903 ) 

904 ) 

905 

906 # Validate each state 

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

908 # Check all referenced states exist 

909 refs = state.get_referenced_states() 

910 for ref in refs: 

911 # $current is a special token for retry 

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

913 errors.append( 

914 ValidationError( 

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

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

917 ) 

918 ) 

919 

920 # Validate action configuration 

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

922 

923 # Validate evaluator if present 

924 if state.evaluate is not None: 

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

926 

927 # Validate routing configuration 

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

929 

930 # Check numeric field ranges 

931 if fsm.max_iterations <= 0: 

932 errors.append( 

933 ValidationError( 

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

935 path="max_iterations", 

936 ) 

937 ) 

938 if fsm.max_edge_revisits <= 0: 

939 errors.append( 

940 ValidationError( 

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

942 path="max_edge_revisits", 

943 ) 

944 ) 

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

946 errors.append( 

947 ValidationError( 

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

949 path="backoff", 

950 ) 

951 ) 

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

953 errors.append( 

954 ValidationError( 

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

956 path="timeout", 

957 ) 

958 ) 

959 if fsm.llm.max_tokens <= 0: 

960 errors.append( 

961 ValidationError( 

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

963 path="llm.max_tokens", 

964 ) 

965 ) 

966 if fsm.llm.timeout <= 0: 

967 errors.append( 

968 ValidationError( 

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

970 path="llm.timeout", 

971 ) 

972 ) 

973 

974 # Check for unreachable states (warning only) 

975 reachable = _find_reachable_states(fsm) 

976 unreachable = defined_states - reachable 

977 for state_name in unreachable: 

978 errors.append( 

979 ValidationError( 

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

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

982 severity=ValidationSeverity.WARNING, 

983 ) 

984 ) 

985 

986 errors.extend(_validate_failure_terminal_action(fsm)) 

987 

988 errors.extend(_validate_meta_loop_evaluation(fsm)) 

989 

990 errors.extend(_validate_input_key_without_guard(fsm)) 

991 

992 errors.extend(_validate_artifact_isolation(fsm)) 

993 

994 errors.extend(_validate_harness_multimodal_evaluator_blind_spot(fsm)) 

995 

996 errors.extend(_validate_partial_route_dead_end(fsm)) 

997 

998 errors.extend(_validate_zero_retry_counter(fsm)) 

999 

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

1001 

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

1003 

1004 errors.extend(_validate_progress_paths_isolation(fsm)) 

1005 

1006 return errors 

1007 

1008 

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

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

1011 

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

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

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

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

1016 3. Any state action references yaml_state_editor or replace_action 

1017 """ 

1018 # Condition 2: imports lib/benchmark.yaml 

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

1020 return True 

1021 # Conditions 1 and 3: scan action strings 

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

1023 if state.action is None: 

1024 continue 

1025 for pattern in _META_LOOP_ACTION_PATTERNS: 

1026 if pattern.search(state.action): 

1027 return True 

1028 for token in _META_LOOP_ACTION_TOKENS: 

1029 if token in state.action: 

1030 return True 

1031 return False 

1032 

1033 

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

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

1036 

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

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

1039 

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

1041 """ 

1042 errors: list[ValidationError] = [] 

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

1044 return errors 

1045 

1046 # Collect all evaluator types used across all states 

1047 evaluator_types: set[str] = set() 

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

1049 if state.evaluate is not None: 

1050 evaluator_types.add(state.evaluate.type) 

1051 

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

1053 if not evaluator_types & NON_LLM_EVALUATOR_TYPES: 

1054 errors.append( 

1055 ValidationError( 

1056 message=( 

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

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

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

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

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

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

1063 "loop top-level." 

1064 ), 

1065 path="<root>", 

1066 severity=ValidationSeverity.ERROR, 

1067 ) 

1068 ) 

1069 

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

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

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

1073 errors.append( 

1074 ValidationError( 

1075 message=( 

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

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

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

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

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

1081 ), 

1082 path="<root>", 

1083 severity=ValidationSeverity.WARNING, 

1084 ) 

1085 ) 

1086 

1087 return errors 

1088 

1089 

1090# Regex patterns for detecting counter-increment actions. 

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

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

1093_COUNTER_INCREMENT_RE = re.compile( 

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

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

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

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

1098) 

1099 

1100 

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

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

1103 

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

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

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

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

1108 """ 

1109 errors: list[ValidationError] = [] 

1110 

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

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

1113 continue 

1114 

1115 ev = state.evaluate 

1116 if ev.type != "output_numeric": 

1117 continue 

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

1119 continue 

1120 

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

1122 try: 

1123 target = float(ev.target) 

1124 except (ValueError, TypeError): 

1125 continue 

1126 

1127 if not _is_counter_action(state.action): 

1128 continue 

1129 

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

1131 op_fn = _NUMERIC_OPERATORS.get(ev.operator) 

1132 if op_fn is None: 

1133 continue 

1134 

1135 if not op_fn(1.0, target): 

1136 suggested_target = _suggested_target(ev.operator, target) 

1137 errors.append( 

1138 ValidationError( 

1139 message=( 

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

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

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

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

1144 ), 

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

1146 severity=ValidationSeverity.WARNING, 

1147 ) 

1148 ) 

1149 

1150 return errors 

1151 

1152 

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

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

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

1156 

1157 

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

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

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

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

1162 return str(int(target) + 1) 

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

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

1165 return "1" 

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

1167 return str(int(target) + 1) 

1168 

1169 

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

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

1172 

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

1174 producing verdicts based on incomplete information. The output_contains 

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

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

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

1178 harness modification. 

1179 

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

1181 """ 

1182 errors: list[ValidationError] = [] 

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

1184 return errors 

1185 

1186 terminal_states = fsm.get_terminal_states() 

1187 

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

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

1190 continue 

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

1192 continue 

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

1194 continue 

1195 if state.on_yes not in terminal_states: 

1196 continue 

1197 errors.append( 

1198 ValidationError( 

1199 message=( 

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

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

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

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

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

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

1206 ), 

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

1208 severity=ValidationSeverity.WARNING, 

1209 ) 

1210 ) 

1211 

1212 return errors 

1213 

1214 

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

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

1217 

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

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

1220 place where loop YAMLs directly encode artifact paths. 

1221 """ 

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

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

1224 if not state.action: 

1225 continue 

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

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

1228 return findings 

1229 

1230 

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

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

1233 

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

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

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

1237 """ 

1238 if fsm.input_key == "input": 

1239 return [] 

1240 if fsm.required_inputs: 

1241 return [] 

1242 return [ 

1243 ValidationError( 

1244 message=( 

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

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

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

1248 f"abort when no input is provided." 

1249 ), 

1250 path="required_inputs", 

1251 severity=ValidationSeverity.WARNING, 

1252 ) 

1253 ] 

1254 

1255 

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

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

1258 

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

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

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

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

1263 repeated invocations). 

1264 

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

1266 intentionally share state across runs. 

1267 """ 

1268 if fsm.shared_state_ok: 

1269 return [] 

1270 errors: list[ValidationError] = [] 

1271 for state_name, path in _find_shared_tmp_writes(fsm): 

1272 errors.append( 

1273 ValidationError( 

1274 message=( 

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

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

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

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

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

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

1281 ), 

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

1283 severity=ValidationSeverity.WARNING, 

1284 ) 

1285 ) 

1286 return errors 

1287 

1288 

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

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

1291 

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

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

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

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

1296 """ 

1297 if state.evaluate is None: 

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

1299 action_type = state.action_type 

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

1301 return True 

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

1303 return True 

1304 return False 

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

1306 

1307 

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

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

1310 

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

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

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

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

1315 

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

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

1318 """ 

1319 if fsm.partial_route_ok: 

1320 return [] 

1321 errors: list[ValidationError] = [] 

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

1323 if not _is_llm_judged(state): 

1324 continue 

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

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

1327 continue 

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

1329 if state.on_yes is None: 

1330 continue 

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

1332 if not missing: 

1333 continue 

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

1335 errors.append( 

1336 ValidationError( 

1337 message=( 

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

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

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

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

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

1343 "if intentional. (ENH-1917)" 

1344 ), 

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

1346 severity=ValidationSeverity.WARNING, 

1347 ) 

1348 ) 

1349 return errors 

1350 

1351 

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

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

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

1355 ev = state.evaluate 

1356 if ev is None: 

1357 continue 

1358 # Check string fields that may interpolate captured values 

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

1360 if isinstance(ev.target, str): 

1361 candidates.append(ev.target) 

1362 for field_val in candidates: 

1363 if not field_val: 

1364 continue 

1365 for name in capture_names: 

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

1367 return True 

1368 return False 

1369 

1370 

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

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

1373 

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

1375 """ 

1376 errors: list[ValidationError] = [] 

1377 if fsm.on_max_iterations is None: 

1378 return errors 

1379 if fsm.on_max_iterations not in defined_states: 

1380 errors.append( 

1381 ValidationError( 

1382 message=( 

1383 f"on_max_iterations references unknown state " 

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

1385 ), 

1386 path="on_max_iterations", 

1387 ) 

1388 ) 

1389 return errors 

1390 

1391 

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

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

1394 

1395 Checks: 

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

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

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

1399 """ 

1400 errors: list[ValidationError] = [] 

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

1402 return errors 

1403 

1404 rf = fsm.circuit.repeated_failure 

1405 if rf.window < 1: 

1406 errors.append( 

1407 ValidationError( 

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

1409 path="circuit.repeated_failure.window", 

1410 ) 

1411 ) 

1412 

1413 target = rf.on_repeated_failure 

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

1415 errors.append( 

1416 ValidationError( 

1417 message=( 

1418 f"circuit.repeated_failure.on_repeated_failure references " 

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

1420 f'the literal "abort")' 

1421 ), 

1422 path="circuit.repeated_failure.on_repeated_failure", 

1423 ) 

1424 ) 

1425 

1426 return errors 

1427 

1428 

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

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

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

1432 

1433 

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

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

1436 return _INTERPOLATION_PREFIX_RE.sub("", path) 

1437 

1438 

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

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

1441 

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

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

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

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

1446 """ 

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

1448 return [] 

1449 rf = fsm.circuit.repeated_failure 

1450 if not rf.progress_paths: 

1451 return [] 

1452 

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

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

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

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

1457 active_watched = watched - excluded 

1458 if not active_watched: 

1459 return [] 

1460 

1461 errors: list[ValidationError] = [] 

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

1463 if not state.action: 

1464 continue 

1465 for path_fragment in active_watched: 

1466 if path_fragment in state.action: 

1467 errors.append( 

1468 ValidationError( 

1469 message=( 

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

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

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

1473 "silently disabling stall detection. Move it to " 

1474 "circuit.repeated_failure.exclude_paths to separate " 

1475 "bookkeeping files from real progress signals." 

1476 ), 

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

1478 severity=ValidationSeverity.WARNING, 

1479 ) 

1480 ) 

1481 return errors 

1482 

1483 

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

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

1486 

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

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

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

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

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

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

1493 

1494 Args: 

1495 fsm: The FSM loop to analyze 

1496 

1497 Returns: 

1498 Set of reachable state names 

1499 """ 

1500 reachable: set[str] = set() 

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

1502 if fsm.on_max_iterations is not None: 

1503 to_visit.append(fsm.on_max_iterations) 

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

1505 target = fsm.circuit.repeated_failure.on_repeated_failure 

1506 if target not in STALL_SPECIAL_TOKENS: 

1507 to_visit.append(target) 

1508 

1509 while to_visit: 

1510 current = to_visit.popleft() 

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

1512 continue 

1513 

1514 reachable.add(current) 

1515 state = fsm.states[current] 

1516 refs = state.get_referenced_states() 

1517 

1518 for ref in refs: 

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

1520 to_visit.append(ref) 

1521 

1522 return reachable 

1523 

1524 

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

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

1527 

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

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

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

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

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

1533 

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

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

1536 """ 

1537 try: 

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

1539 except (OSError, yaml.YAMLError): 

1540 return False 

1541 if not isinstance(data, dict): 

1542 return False 

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

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

1545 

1546 

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

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

1549 

1550 Args: 

1551 path: Path to the YAML file to load 

1552 

1553 Returns: 

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

1555 

1556 Raises: 

1557 FileNotFoundError: If the file doesn't exist 

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

1559 ValueError: If validation fails (contains error details) 

1560 """ 

1561 if not path.exists(): 

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

1563 

1564 with open(path) as f: 

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

1566 

1567 if not isinstance(data, dict): 

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

1569 

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

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

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

1573 # result for the subsequent `resolve_fragments` pass. 

1574 data = resolve_inheritance(data, path.parent) 

1575 

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

1577 data = resolve_flow(data) 

1578 

1579 # Check required fields before parsing 

1580 missing = [] 

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

1582 if field not in data: 

1583 missing.append(field) 

1584 if "states" not in data: 

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

1586 

1587 if missing: 

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

1589 

1590 # Check for unknown top-level keys before parsing 

1591 unknown_key_warnings: list[ValidationError] = [] 

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

1593 if unknown: 

1594 unknown_key_warnings.append( 

1595 ValidationError( 

1596 path="<root>", 

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

1598 severity=ValidationSeverity.WARNING, 

1599 ) 

1600 ) 

1601 

1602 # Resolve fragment libraries before parsing into dataclass 

1603 data = resolve_fragments(data, path.parent) 

1604 

1605 # Parse into dataclass 

1606 fsm = FSMLoop.from_dict(data) 

1607 

1608 # Validate 

1609 errors = validate_fsm(fsm) 

1610 

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

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

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

1614 

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

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

1617 

1618 if error_list: 

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

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

1621 

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

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

1624 all_warnings = unknown_key_warnings + struct_warnings 

1625 for warning in all_warnings: 

1626 logger.warning(str(warning)) 

1627 

1628 return fsm, all_warnings