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

737 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:12 -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 "classify": [], 

78} 

79 

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

81# Derived from EVALUATOR_REQUIRED_FIELDS so new types are automatically included 

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

83 "llm_structured", 

84 "comparator", 

85 "contract", 

86} 

87 

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

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

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

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

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

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

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

95) 

96 

97# Action string tokens that indicate meta-loop behavior 

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

99 

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

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

102 

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

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

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

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

107 

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

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

110 

111# Full-reference form, capturing the var name and the remainder up to the closing 

112# brace so we can detect a `:default=` guard. A reference written as 

113# `${captured.x.output:default=...}` is provably safe even on paths that bypass 

114# the capturing state — the interpolation engine (interpolation.py) substitutes 

115# the default when the path is missing — so it must NOT be flagged by the 

116# capture-reachability check. _CAPTURED_REF_RE alone can't see the guard. 

117_CAPTURED_REF_FULL_RE = re.compile(r"\$\{captured\.(\w+)([^}]*)\}") 

118 

119 

120def _unguarded_captured_refs(text: str) -> set[str]: 

121 """Return captured var names that have at least one reference WITHOUT a 

122 `:default=` guard. Vars referenced only via `${captured.x...:default=...}` 

123 are omitted: the default makes a missing value safe, so they should not 

124 trigger missing-capture or bypass-path diagnostics. 

125 """ 

126 refs: set[str] = set() 

127 for var_name, remainder in _CAPTURED_REF_FULL_RE.findall(text): 

128 if ":default=" not in remainder: 

129 refs.add(var_name) 

130 return refs 

131 

132 

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

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

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

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

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

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

139) 

140 

141# Valid comparison operators 

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

143 

144# Valid values for the top-level `visibility:` field (audience axis for 

145# `ll-loop list` filtering). "public" is the default when the field is absent. 

146VALID_VISIBILITY: frozenset[str] = frozenset({"public", "internal", "example"}) 

147 

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

149KNOWN_TOP_LEVEL_KEYS: frozenset[str] = frozenset( 

150 { 

151 "name", 

152 "description", 

153 "initial", 

154 "states", 

155 "context", 

156 "parameters", 

157 "scope", 

158 "max_iterations", 

159 "on_max_iterations", 

160 "max_edge_revisits", 

161 "backoff", 

162 "timeout", 

163 "default_timeout", 

164 "maintain", 

165 "llm", 

166 "on_handoff", 

167 "input_key", 

168 "required_inputs", 

169 "config", 

170 "category", 

171 "labels", 

172 "visibility", 

173 "commands", 

174 "targets", 

175 "circuit", 

176 "meta_self_eval_ok", 

177 "shared_state_ok", 

178 "partial_route_ok", 

179 "artifact_versioning", 

180 "artifact_versioning_ok", 

181 "generator_fix_ok", 

182 "import", 

183 "fragments", 

184 "from", 

185 "flow", 

186 "state_defs", 

187 } 

188) 

189 

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

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

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

193 

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

195VALID_PARAMETER_TYPES: frozenset[str] = frozenset( 

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

197) 

198 

199 

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

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

202 

203 Args: 

204 state_name: Name of the state containing this evaluator 

205 evaluate: The evaluator configuration to validate 

206 

207 Returns: 

208 List of validation errors found 

209 """ 

210 errors: list[ValidationError] = [] 

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

212 

213 # Check that evaluator type is recognized 

214 valid_types = set(EVALUATOR_REQUIRED_FIELDS.keys()) 

215 if evaluate.type not in valid_types: 

216 errors.append( 

217 ValidationError( 

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

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

220 path=path, 

221 ) 

222 ) 

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

224 

225 # Check required fields for evaluator type 

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

227 for field_name in required: 

228 value = getattr(evaluate, field_name, None) 

229 if value is None: 

230 errors.append( 

231 ValidationError( 

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

233 path=path, 

234 ) 

235 ) 

236 

237 # Validate operator if present 

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

239 errors.append( 

240 ValidationError( 

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

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

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

244 ) 

245 ) 

246 

247 # Validate convergence-specific fields 

248 if evaluate.type == "convergence": 

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

250 errors.append( 

251 ValidationError( 

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

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

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

255 ) 

256 ) 

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

258 if ( 

259 evaluate.tolerance is not None 

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

261 and evaluate.tolerance < 0 

262 ): 

263 errors.append( 

264 ValidationError( 

265 message="Tolerance cannot be negative", 

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

267 ) 

268 ) 

269 

270 # Validate llm_structured-specific fields 

271 if evaluate.type == "llm_structured": 

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

273 errors.append( 

274 ValidationError( 

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

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

277 ) 

278 ) 

279 

280 # Validate diff_stall-specific fields 

281 if evaluate.type == "diff_stall": 

282 if evaluate.max_stall < 1: 

283 errors.append( 

284 ValidationError( 

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

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

287 ) 

288 ) 

289 

290 # Validate action_stall-specific fields 

291 if evaluate.type == "action_stall": 

292 if evaluate.max_repeat < 1: 

293 errors.append( 

294 ValidationError( 

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

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

297 ) 

298 ) 

299 

300 return errors 

301 

302 

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

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

305 

306 Args: 

307 fsm: The FSM loop to validate 

308 

309 Returns: 

310 List of validation errors found 

311 """ 

312 errors: list[ValidationError] = [] 

313 

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

315 path = f"parameters.{param_name}" 

316 

317 if param_spec.type not in VALID_PARAMETER_TYPES: 

318 errors.append( 

319 ValidationError( 

320 message=( 

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

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

323 ), 

324 path=path, 

325 ) 

326 ) 

327 

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

329 errors.append( 

330 ValidationError( 

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

332 path=path, 

333 ) 

334 ) 

335 

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

337 errors.append( 

338 ValidationError( 

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

340 path=path, 

341 ) 

342 ) 

343 

344 return errors 

345 

346 

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

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

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

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

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

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

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

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

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

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

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

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

359 return None 

360 

361 

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

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

364 

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

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

367 

368 Args: 

369 fsm: The parent FSM loop 

370 loop_dir: Directory to resolve child loop paths from 

371 

372 Returns: 

373 List of validation errors found 

374 """ 

375 errors: list[ValidationError] = [] 

376 

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

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

379 continue 

380 

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

382 try: 

383 from little_loops.cli.loop._helpers import resolve_loop_path 

384 

385 loop_path = resolve_loop_path(state.loop, loop_dir) 

386 child_fsm, _ = load_and_validate(loop_path) 

387 except Exception: 

388 continue 

389 

390 if not child_fsm.parameters: 

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

392 

393 path = f"states.{state_name}" 

394 

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

396 for key in state.with_: 

397 if key not in child_fsm.parameters: 

398 errors.append( 

399 ValidationError( 

400 message=( 

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

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

403 ), 

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

405 ) 

406 ) 

407 

408 # Required parameters not bound 

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

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

411 errors.append( 

412 ValidationError( 

413 message=( 

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

415 f"is not bound in 'with'" 

416 ), 

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

418 ) 

419 ) 

420 

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

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

423 if param_name not in child_fsm.parameters: 

424 continue 

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

426 continue 

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

428 if type_error: 

429 errors.append( 

430 ValidationError( 

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

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

433 ) 

434 ) 

435 

436 return errors 

437 

438 

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

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

441 

442 Called from load_and_validate (not validate_fsm) because fragment parameters 

443 are populated by resolve_fragments which runs before dataclass parsing. 

444 

445 Args: 

446 fsm: The FSM loop to validate 

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

448 _validate_with_bindings) 

449 

450 Returns: 

451 List of validation errors found 

452 """ 

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

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

455 

456 errors: list[ValidationError] = [] 

457 

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

459 if not state.fragment_parameters: 

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

461 

462 path = f"states.{state_name}" 

463 

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

465 for key in state.fragment_bindings: 

466 if key not in state.fragment_parameters: 

467 errors.append( 

468 ValidationError( 

469 message=( 

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

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

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

473 ), 

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

475 ) 

476 ) 

477 

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

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

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

481 if param_name in RUNNER_INJECTED: 

482 continue # Available at runtime; not a static error 

483 errors.append( 

484 ValidationError( 

485 message=( 

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

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

488 ), 

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

490 ) 

491 ) 

492 

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

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

495 if param_name not in state.fragment_parameters: 

496 continue 

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

498 continue 

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

500 if type_error: 

501 errors.append( 

502 ValidationError( 

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

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

505 ) 

506 ) 

507 

508 return errors 

509 

510 

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

512 """Validate state action configuration. 

513 

514 Args: 

515 state_name: Name of the state to validate 

516 state: The state configuration to validate 

517 

518 Returns: 

519 List of validation errors found 

520 """ 

521 errors: list[ValidationError] = [] 

522 path = f"states.{state_name}" 

523 

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

525 if state.append_to_messages is not None: 

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

527 errors.append( 

528 ValidationError( 

529 message=( 

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

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

532 ), 

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

534 ) 

535 ) 

536 

537 # model: override is silently ignored for non-prompt states (host CLI is not invoked) 

538 if state.model is not None and state.action_type not in ("prompt", "slash_command", None): 

539 errors.append( 

540 ValidationError( 

541 message="model: override is ignored for shell/mcp_tool/contract states", 

542 path=f"{path}.model", 

543 severity=ValidationSeverity.WARNING, 

544 ) 

545 ) 

546 

547 # params field is only valid for mcp_tool states 

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

549 errors.append( 

550 ValidationError( 

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

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

553 ) 

554 ) 

555 

556 # loop and action are mutually exclusive 

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

558 errors.append( 

559 ValidationError( 

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

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

562 path=f"{path}", 

563 ) 

564 ) 

565 

566 # with: requires loop: to be set 

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

568 errors.append( 

569 ValidationError( 

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

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

572 ) 

573 ) 

574 

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

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

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

578 errors.append( 

579 ValidationError( 

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

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

582 ) 

583 ) 

584 if state.learning.max_retries < 0: 

585 errors.append( 

586 ValidationError( 

587 message=( 

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

589 ), 

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

591 ) 

592 ) 

593 if state.on_yes is None: 

594 errors.append( 

595 ValidationError( 

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

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

598 ) 

599 ) 

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

601 errors.append( 

602 ValidationError( 

603 message=( 

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

605 "(target for refuted / retries_exhausted)" 

606 ), 

607 path=f"{path}", 

608 ) 

609 ) 

610 

611 # with: and context_passthrough are mutually exclusive 

612 if state.with_ and state.context_passthrough: 

613 errors.append( 

614 ValidationError( 

615 message=( 

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

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

618 "for legacy bulk passthrough, not both" 

619 ), 

620 path=f"{path}", 

621 ) 

622 ) 

623 

624 return errors 

625 

626 

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

628 """Validate state routing configuration. 

629 

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

631 

632 Args: 

633 state_name: Name of the state to validate 

634 state: The state configuration to validate 

635 

636 Returns: 

637 List of validation errors/warnings found 

638 """ 

639 errors: list[ValidationError] = [] 

640 path = f"states.{state_name}" 

641 

642 has_shorthand = ( 

643 state.on_yes is not None 

644 or state.on_no is not None 

645 or state.on_error is not None 

646 or state.on_partial is not None 

647 or state.on_blocked is not None 

648 or bool(state.extra_routes) 

649 ) 

650 has_route = state.route is not None 

651 

652 # Warn about conflicting definitions 

653 if has_shorthand and has_route: 

654 errors.append( 

655 ValidationError( 

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

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

658 path=path, 

659 severity=ValidationSeverity.WARNING, 

660 ) 

661 ) 

662 

663 # Check for no valid transition definition 

664 has_next = state.next is not None 

665 has_terminal = state.terminal 

666 has_loop = state.loop is not None 

667 

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

669 errors.append( 

670 ValidationError( 

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

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

673 path=path, 

674 ) 

675 ) 

676 

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

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

679 errors.append( 

680 ValidationError( 

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

682 path=path, 

683 ) 

684 ) 

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

686 errors.append( 

687 ValidationError( 

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

689 path=path, 

690 ) 

691 ) 

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

693 errors.append( 

694 ValidationError( 

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

696 path=path, 

697 ) 

698 ) 

699 

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

701 if state.retryable_exit_codes is not None: 

702 if state.on_error is None: 

703 errors.append( 

704 ValidationError( 

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

706 path=path, 

707 ) 

708 ) 

709 for code in state.retryable_exit_codes: 

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

711 errors.append( 

712 ValidationError( 

713 message=( 

714 f"'retryable_exit_codes' entries must be positive " 

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

716 ), 

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

718 ) 

719 ) 

720 break 

721 

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

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

724 errors.append( 

725 ValidationError( 

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

727 path=path, 

728 ) 

729 ) 

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

731 errors.append( 

732 ValidationError( 

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

734 path=path, 

735 ) 

736 ) 

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

738 errors.append( 

739 ValidationError( 

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

741 path=path, 

742 ) 

743 ) 

744 if ( 

745 state.rate_limit_backoff_base_seconds is not None 

746 and state.rate_limit_backoff_base_seconds < 1 

747 ): 

748 errors.append( 

749 ValidationError( 

750 message=( 

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

752 f"got {state.rate_limit_backoff_base_seconds}" 

753 ), 

754 path=path, 

755 ) 

756 ) 

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

758 errors.append( 

759 ValidationError( 

760 message=( 

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

762 f"got {state.rate_limit_max_wait_seconds}" 

763 ), 

764 path=path, 

765 ) 

766 ) 

767 if state.rate_limit_long_wait_ladder is not None: 

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

769 errors.append( 

770 ValidationError( 

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

772 path=path, 

773 ) 

774 ) 

775 else: 

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

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

778 errors.append( 

779 ValidationError( 

780 message=( 

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

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

783 ), 

784 path=path, 

785 ) 

786 ) 

787 

788 # Validate throttle config when present 

789 if state.throttle is not None: 

790 t = state.throttle 

791 fields = { 

792 "normal_max": t.normal_max, 

793 "warn_max": t.warn_max, 

794 "hard_max": t.hard_max, 

795 } 

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

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

798 errors.append( 

799 ValidationError( 

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

801 path=path, 

802 ) 

803 ) 

804 # Enforce ordering when all three are set 

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

806 errors.append( 

807 ValidationError( 

808 message=( 

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

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

811 ), 

812 path=path, 

813 ) 

814 ) 

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

816 errors.append( 

817 ValidationError( 

818 message=( 

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

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

821 ), 

822 path=path, 

823 ) 

824 ) 

825 

826 return errors 

827 

828 

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

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

831 

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

833 end with a .yaml extension. 

834 """ 

835 errors: list[ValidationError] = [] 

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

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

838 errors.append( 

839 ValidationError( 

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

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

842 ) 

843 ) 

844 return errors 

845 

846 

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

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

849 

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

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

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

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

854 itself can execute. 

855 

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

857 failure terminals continue to load, and test_terminal_only_state_valid 

858 (which filters by ERROR) passes without modification. 

859 """ 

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

861 errors: list[ValidationError] = [] 

862 

863 terminal_states = fsm.get_terminal_states() 

864 failure_terminals = terminal_states & FAILURE_TERMINAL_NAMES 

865 

866 for ft_name in failure_terminals: 

867 has_diagnostic_predecessor = False 

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

869 if state_name == ft_name: 

870 continue 

871 if ft_name in state.get_referenced_states(): 

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

873 has_diagnostic_predecessor = True 

874 break 

875 

876 if not has_diagnostic_predecessor: 

877 errors.append( 

878 ValidationError( 

879 message=( 

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

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

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

883 f"to '{ft_name}'." 

884 ), 

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

886 severity=ValidationSeverity.WARNING, 

887 ) 

888 ) 

889 

890 return errors 

891 

892 

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

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

895 

896 Performs comprehensive validation: 

897 - Initial state exists 

898 - All referenced states exist 

899 - At least one terminal state 

900 - Evaluator configurations are valid 

901 - Routing configurations are valid 

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

903 

904 Args: 

905 fsm: The FSM loop to validate 

906 

907 Returns: 

908 List of validation errors (empty if valid) 

909 """ 

910 errors: list[ValidationError] = [] 

911 defined_states = fsm.get_all_state_names() 

912 

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

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

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

916 if not fsm.description: 

917 errors.append( 

918 ValidationError( 

919 path="<root>", 

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

921 severity=ValidationSeverity.WARNING, 

922 ) 

923 ) 

924 

925 # Validate parameters block 

926 errors.extend(_validate_parameters(fsm)) 

927 

928 # Validate targets block (ENH-1552) 

929 errors.extend(_validate_targets(fsm)) 

930 

931 # Check initial state exists 

932 if fsm.initial not in defined_states: 

933 errors.append( 

934 ValidationError( 

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

936 path="initial", 

937 ) 

938 ) 

939 

940 # Check at least one terminal state 

941 terminal_states = fsm.get_terminal_states() 

942 if not terminal_states: 

943 errors.append( 

944 ValidationError( 

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

946 path="states", 

947 ) 

948 ) 

949 

950 # Validate each state 

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

952 # Check all referenced states exist 

953 refs = state.get_referenced_states() 

954 for ref in refs: 

955 # $current is a special token for retry 

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

957 errors.append( 

958 ValidationError( 

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

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

961 ) 

962 ) 

963 

964 # Validate action configuration 

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

966 

967 # Validate evaluator if present 

968 if state.evaluate is not None: 

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

970 

971 # Validate routing configuration 

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

973 

974 # Check numeric field ranges 

975 if fsm.max_iterations <= 0: 

976 errors.append( 

977 ValidationError( 

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

979 path="max_iterations", 

980 ) 

981 ) 

982 if fsm.max_edge_revisits <= 0: 

983 errors.append( 

984 ValidationError( 

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

986 path="max_edge_revisits", 

987 ) 

988 ) 

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

990 errors.append( 

991 ValidationError( 

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

993 path="backoff", 

994 ) 

995 ) 

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

997 errors.append( 

998 ValidationError( 

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

1000 path="timeout", 

1001 ) 

1002 ) 

1003 if fsm.llm.max_tokens <= 0: 

1004 errors.append( 

1005 ValidationError( 

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

1007 path="llm.max_tokens", 

1008 ) 

1009 ) 

1010 if fsm.llm.timeout <= 0: 

1011 errors.append( 

1012 ValidationError( 

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

1014 path="llm.timeout", 

1015 ) 

1016 ) 

1017 

1018 # Check for unreachable states (warning only) 

1019 reachable = _find_reachable_states(fsm) 

1020 unreachable = defined_states - reachable 

1021 for state_name in unreachable: 

1022 errors.append( 

1023 ValidationError( 

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

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

1026 severity=ValidationSeverity.WARNING, 

1027 ) 

1028 ) 

1029 

1030 errors.extend(_validate_failure_terminal_action(fsm)) 

1031 

1032 errors.extend(_validate_meta_loop_evaluation(fsm)) 

1033 

1034 errors.extend(_validate_input_key_without_guard(fsm)) 

1035 

1036 errors.extend(_validate_artifact_isolation(fsm)) 

1037 

1038 errors.extend(_validate_harness_multimodal_evaluator_blind_spot(fsm)) 

1039 

1040 errors.extend(_validate_partial_route_dead_end(fsm)) 

1041 

1042 errors.extend(_validate_artifact_overwrite(fsm)) 

1043 

1044 errors.extend(_validate_generator_fix_discipline(fsm)) 

1045 

1046 errors.extend(_validate_classify_route_default(fsm)) 

1047 

1048 errors.extend(_validate_zero_retry_counter(fsm)) 

1049 

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

1051 

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

1053 

1054 errors.extend(_validate_progress_paths_isolation(fsm)) 

1055 

1056 errors.extend(_validate_capture_reachability(fsm)) 

1057 

1058 return errors 

1059 

1060 

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

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

1063 

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

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

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

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

1068 3. Any state action references yaml_state_editor or replace_action 

1069 """ 

1070 # Condition 2: imports lib/benchmark.yaml 

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

1072 return True 

1073 # Conditions 1 and 3: scan action strings 

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

1075 if state.action is None: 

1076 continue 

1077 for pattern in _META_LOOP_ACTION_PATTERNS: 

1078 if pattern.search(state.action): 

1079 return True 

1080 for token in _META_LOOP_ACTION_TOKENS: 

1081 if token in state.action: 

1082 return True 

1083 return False 

1084 

1085 

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

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

1088 

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

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

1091 

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

1093 """ 

1094 errors: list[ValidationError] = [] 

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

1096 return errors 

1097 

1098 # Collect all evaluator types used across all states 

1099 evaluator_types: set[str] = set() 

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

1101 if state.evaluate is not None: 

1102 evaluator_types.add(state.evaluate.type) 

1103 

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

1105 if not evaluator_types & NON_LLM_EVALUATOR_TYPES: 

1106 errors.append( 

1107 ValidationError( 

1108 message=( 

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

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

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

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

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

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

1115 "loop top-level." 

1116 ), 

1117 path="<root>", 

1118 severity=ValidationSeverity.ERROR, 

1119 ) 

1120 ) 

1121 

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

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

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

1125 errors.append( 

1126 ValidationError( 

1127 message=( 

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

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

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

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

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

1133 ), 

1134 path="<root>", 

1135 severity=ValidationSeverity.WARNING, 

1136 ) 

1137 ) 

1138 

1139 return errors 

1140 

1141 

1142# Regex patterns for detecting counter-increment actions. 

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

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

1145_COUNTER_INCREMENT_RE = re.compile( 

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

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

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

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

1150) 

1151 

1152 

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

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

1155 

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

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

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

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

1160 """ 

1161 errors: list[ValidationError] = [] 

1162 

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

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

1165 continue 

1166 

1167 ev = state.evaluate 

1168 if ev.type != "output_numeric": 

1169 continue 

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

1171 continue 

1172 

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

1174 try: 

1175 target = float(ev.target) 

1176 except (ValueError, TypeError): 

1177 continue 

1178 

1179 if not _is_counter_action(state.action): 

1180 continue 

1181 

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

1183 op_fn = _NUMERIC_OPERATORS.get(ev.operator) 

1184 if op_fn is None: 

1185 continue 

1186 

1187 if not op_fn(1.0, target): 

1188 suggested_target = _suggested_target(ev.operator, target) 

1189 errors.append( 

1190 ValidationError( 

1191 message=( 

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

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

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

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

1196 ), 

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

1198 severity=ValidationSeverity.WARNING, 

1199 ) 

1200 ) 

1201 

1202 return errors 

1203 

1204 

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

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

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

1208 

1209 

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

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

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

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

1214 return str(int(target) + 1) 

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

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

1217 return "1" 

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

1219 return str(int(target) + 1) 

1220 

1221 

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

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

1224 

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

1226 producing verdicts based on incomplete information. The output_contains 

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

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

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

1230 harness modification. 

1231 

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

1233 """ 

1234 errors: list[ValidationError] = [] 

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

1236 return errors 

1237 

1238 terminal_states = fsm.get_terminal_states() 

1239 

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

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

1242 continue 

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

1244 continue 

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

1246 continue 

1247 if state.on_yes not in terminal_states: 

1248 continue 

1249 errors.append( 

1250 ValidationError( 

1251 message=( 

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

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

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

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

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

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

1258 ), 

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

1260 severity=ValidationSeverity.WARNING, 

1261 ) 

1262 ) 

1263 

1264 return errors 

1265 

1266 

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

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

1269 

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

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

1272 place where loop YAMLs directly encode artifact paths. 

1273 """ 

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

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

1276 if not state.action: 

1277 continue 

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

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

1280 return findings 

1281 

1282 

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

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

1285 

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

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

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

1289 """ 

1290 if fsm.input_key == "input": 

1291 return [] 

1292 if fsm.required_inputs: 

1293 return [] 

1294 return [ 

1295 ValidationError( 

1296 message=( 

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

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

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

1300 f"abort when no input is provided." 

1301 ), 

1302 path="required_inputs", 

1303 severity=ValidationSeverity.WARNING, 

1304 ) 

1305 ] 

1306 

1307 

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

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

1310 

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

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

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

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

1315 repeated invocations). 

1316 

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

1318 intentionally share state across runs. 

1319 """ 

1320 if fsm.shared_state_ok: 

1321 return [] 

1322 errors: list[ValidationError] = [] 

1323 for state_name, path in _find_shared_tmp_writes(fsm): 

1324 errors.append( 

1325 ValidationError( 

1326 message=( 

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

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

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

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

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

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

1333 ), 

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

1335 severity=ValidationSeverity.WARNING, 

1336 ) 

1337 ) 

1338 return errors 

1339 

1340 

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

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

1343 

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

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

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

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

1348 """ 

1349 if state.evaluate is None: 

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

1351 action_type = state.action_type 

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

1353 return True 

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

1355 return True 

1356 return False 

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

1358 

1359 

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

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

1362 

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

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

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

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

1367 

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

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

1370 """ 

1371 if fsm.partial_route_ok: 

1372 return [] 

1373 errors: list[ValidationError] = [] 

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

1375 if not _is_llm_judged(state): 

1376 continue 

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

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

1379 continue 

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

1381 if state.on_yes is None: 

1382 continue 

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

1384 if not missing: 

1385 continue 

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

1387 errors.append( 

1388 ValidationError( 

1389 message=( 

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

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

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

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

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

1395 "if intentional. (ENH-1917)" 

1396 ), 

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

1398 severity=ValidationSeverity.WARNING, 

1399 ) 

1400 ) 

1401 return errors 

1402 

1403 

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

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

1406 

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

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

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

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

1411 

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

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

1414 by task). 

1415 """ 

1416 if fsm.artifact_versioning or fsm.artifact_versioning_ok: 

1417 return [] 

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

1419 return [] 

1420 

1421 errors: list[ValidationError] = [] 

1422 

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

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

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

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

1427 continue 

1428 # Skip sub-loop delegation states 

1429 if state.action_type == "loop": 

1430 continue 

1431 action = state.action 

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

1433 import re 

1434 

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

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

1437 artifact_refs = set() 

1438 for pattern in ( 

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

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

1441 ): 

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

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

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

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

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

1447 if artifact_refs: 

1448 writers[state_name] = artifact_refs 

1449 

1450 if not writers: 

1451 return [] 

1452 

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

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

1455 for state_name in writers: 

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

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

1458 visited: set[str] = set() 

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

1460 while to_visit: 

1461 target = to_visit.pop() 

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

1463 continue 

1464 visited.add(target) 

1465 if target == state_name: 

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

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

1468 errors.append( 

1469 ValidationError( 

1470 message=( 

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

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

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

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

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

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

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

1478 ), 

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

1480 severity=ValidationSeverity.WARNING, 

1481 ) 

1482 ) 

1483 break 

1484 target_state = fsm.states.get(target) 

1485 if target_state is not None: 

1486 target_refs = target_state.get_referenced_states() 

1487 for r in target_refs: 

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

1489 to_visit.append(r) 

1490 

1491 return errors 

1492 

1493 

1494def _validate_generator_fix_discipline(fsm: FSMLoop) -> list[ValidationError]: 

1495 """Validate rule MR-6 (ENH-2079): meta-loops should not hand-patch generator artifacts. 

1496 

1497 Detects the hand-patching anti-pattern: a ``shell``-type state that writes to the 

1498 same file path as a non-shell (LLM-type) generator state in the same loop. 

1499 Hand-patching creates fragile output that diverges from the generator on the next 

1500 run; the stable fix is to update the generator action instead. 

1501 

1502 Suppressed by ``generator_fix_ok: true`` at the loop top-level for intentional 

1503 post-processing cases. 

1504 """ 

1505 if fsm.generator_fix_ok or not _is_meta_loop(fsm): 

1506 return [] 

1507 

1508 # Markers that indicate a prompt/slash_command state is generating file artifacts 

1509 _GENERATOR_MARKERS = ("yaml_state_editor", "replace_action", "to_file:") 

1510 

1511 _PATH_PATTERNS = ( 

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

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

1514 ) 

1515 

1516 def _extract_paths(action: str) -> set[str]: 

1517 paths: set[str] = set() 

1518 for pat in _PATH_PATTERNS: 

1519 for m in pat.finditer(action): 

1520 paths.add(m.group(1).rstrip("/")) 

1521 return paths 

1522 

1523 shell_targets: dict[str, set[str]] = {} # state_name -> set of file paths 

1524 generator_targets: dict[str, set[str]] = {} # state_name -> set of file paths 

1525 

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

1527 if not state.action: 

1528 continue 

1529 action = state.action 

1530 paths = _extract_paths(action) 

1531 if not paths: 

1532 continue 

1533 action_type = state.action_type 

1534 if action_type in ("shell", None): 

1535 shell_targets[state_name] = paths 

1536 elif action_type in ("prompt", "slash_command"): 

1537 if any(marker in action for marker in _GENERATOR_MARKERS): 

1538 generator_targets[state_name] = paths 

1539 

1540 errors: list[ValidationError] = [] 

1541 for gen_name, gen_paths in generator_targets.items(): 

1542 for shell_name, shell_paths in shell_targets.items(): 

1543 overlap = gen_paths & shell_paths 

1544 if overlap: 

1545 artifact_list = ", ".join(sorted(overlap)) 

1546 errors.append( 

1547 ValidationError( 

1548 message=( 

1549 f"[states: {gen_name}, {shell_name}] Hand-patching anti-pattern: " 

1550 f"LLM-generator state '{gen_name}' and shell state '{shell_name}' " 

1551 f"both write to ({artifact_list}). Move the fix into the generator " 

1552 "action so every run produces correct output automatically. " 

1553 "Set `generator_fix_ok: true` to suppress for intentional " 

1554 "post-processing. (ENH-2079)" 

1555 ), 

1556 path=f"states.{shell_name}", 

1557 severity=ValidationSeverity.WARNING, 

1558 ) 

1559 ) 

1560 

1561 return errors 

1562 

1563 

1564def _validate_classify_route_default(fsm: FSMLoop) -> list[ValidationError]: 

1565 """Validate that classify states with a route: table include a default: fallback. 

1566 

1567 A ``classify`` state whose ``route:`` table has no ``default:`` will dead-end 

1568 whenever the action emits a token not listed in the table. This rule flags 

1569 that gap as a WARNING so loop authors add a catch-all branch. 

1570 

1571 Suppressed by ``partial_route_ok: true`` at the loop top-level when a 

1572 dead-end on an unlisted token is intentional. 

1573 """ 

1574 if fsm.partial_route_ok: 

1575 return [] 

1576 errors: list[ValidationError] = [] 

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

1578 if state.evaluate is None or state.evaluate.type != "classify": 

1579 continue 

1580 if state.route is None or state.route.default is not None: 

1581 continue 

1582 errors.append( 

1583 ValidationError( 

1584 message=( 

1585 f"[state: {state_name}] classify route: table has no default: — " 

1586 "unknown tokens will dead-end the loop. Add a default: catch-all, " 

1587 "or set `partial_route_ok: true` at the loop top-level to suppress." 

1588 ), 

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

1590 severity=ValidationSeverity.WARNING, 

1591 ) 

1592 ) 

1593 return errors 

1594 

1595 

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

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

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

1599 ev = state.evaluate 

1600 if ev is None: 

1601 continue 

1602 # Check string fields that may interpolate captured values 

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

1604 if isinstance(ev.target, str): 

1605 candidates.append(ev.target) 

1606 for field_val in candidates: 

1607 if not field_val: 

1608 continue 

1609 for name in capture_names: 

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

1611 return True 

1612 return False 

1613 

1614 

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

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

1617 

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

1619 """ 

1620 errors: list[ValidationError] = [] 

1621 if fsm.on_max_iterations is None: 

1622 return errors 

1623 if fsm.on_max_iterations not in defined_states: 

1624 errors.append( 

1625 ValidationError( 

1626 message=( 

1627 f"on_max_iterations references unknown state " 

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

1629 ), 

1630 path="on_max_iterations", 

1631 ) 

1632 ) 

1633 return errors 

1634 

1635 

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

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

1638 

1639 Checks: 

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

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

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

1643 """ 

1644 errors: list[ValidationError] = [] 

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

1646 return errors 

1647 

1648 rf = fsm.circuit.repeated_failure 

1649 if rf.window < 1: 

1650 errors.append( 

1651 ValidationError( 

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

1653 path="circuit.repeated_failure.window", 

1654 ) 

1655 ) 

1656 

1657 target = rf.on_repeated_failure 

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

1659 errors.append( 

1660 ValidationError( 

1661 message=( 

1662 f"circuit.repeated_failure.on_repeated_failure references " 

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

1664 f'the literal "abort")' 

1665 ), 

1666 path="circuit.repeated_failure.on_repeated_failure", 

1667 ) 

1668 ) 

1669 

1670 return errors 

1671 

1672 

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

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

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

1676 

1677 

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

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

1680 return _INTERPOLATION_PREFIX_RE.sub("", path) 

1681 

1682 

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

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

1685 

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

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

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

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

1690 """ 

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

1692 return [] 

1693 rf = fsm.circuit.repeated_failure 

1694 if not rf.progress_paths: 

1695 return [] 

1696 

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

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

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

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

1701 active_watched = watched - excluded 

1702 if not active_watched: 

1703 return [] 

1704 

1705 errors: list[ValidationError] = [] 

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

1707 if not state.action: 

1708 continue 

1709 for path_fragment in active_watched: 

1710 if path_fragment in state.action: 

1711 errors.append( 

1712 ValidationError( 

1713 message=( 

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

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

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

1717 "silently disabling stall detection. Move it to " 

1718 "circuit.repeated_failure.exclude_paths to separate " 

1719 "bookkeeping files from real progress signals." 

1720 ), 

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

1722 severity=ValidationSeverity.WARNING, 

1723 ) 

1724 ) 

1725 return errors 

1726 

1727 

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

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

1730 

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

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

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

1734 reachable from the initial state. 

1735 

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

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

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

1739 

1740 Args: 

1741 fsm: The FSM loop to analyze 

1742 dominators: Names of the states that should collectively dominate 

1743 dominated: Name of the state that should be dominated 

1744 

1745 Returns: 

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

1747 """ 

1748 if dominated in dominators: 

1749 return True 

1750 if dominated not in fsm.states: 

1751 return False 

1752 

1753 visited: set[str] = set() 

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

1755 

1756 while to_visit: 

1757 current = to_visit.popleft() 

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

1759 continue 

1760 if current in dominators: 

1761 continue # Block this node (simulate removal) 

1762 

1763 visited.add(current) 

1764 

1765 if current == dominated: 

1766 # Reached dominated without going through any dominator 

1767 return False 

1768 

1769 state = fsm.states[current] 

1770 for ref in state.get_referenced_states(): 

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

1772 to_visit.append(ref) 

1773 

1774 # Dominated not reachable without the dominators → they dominate 

1775 return True 

1776 

1777 

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

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

1780 

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

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

1783 """ 

1784 if dominator not in fsm.states: 

1785 return False 

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

1787 

1788 

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

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

1791 

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

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

1794 :func:`_dominated_by_any` returns False). 

1795 """ 

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

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

1798 visited: set[str] = set() 

1799 

1800 while to_visit: 

1801 current = to_visit.popleft() 

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

1803 continue 

1804 if current in dominators: 

1805 continue 

1806 

1807 visited.add(current) 

1808 

1809 if current == dominated: 

1810 # Reconstruct path 

1811 path = [dominated] 

1812 while path[-1] in parent: 

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

1814 path.reverse() 

1815 return path 

1816 

1817 state = fsm.states[current] 

1818 for ref in state.get_referenced_states(): 

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

1820 if ref not in parent: 

1821 parent[ref] = current 

1822 to_visit.append(ref) 

1823 

1824 return [] 

1825 

1826 

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

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

1829 

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

1831 """ 

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

1833 

1834 

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

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

1837 

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

1839 """ 

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

1841 

1842 

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

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

1845 

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

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

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

1849 

1850 Emits: 

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

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

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

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

1855 """ 

1856 errors: list[ValidationError] = [] 

1857 

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

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

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

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

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

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

1864 if state.capture: 

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

1866 

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

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

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

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

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

1872 if state.loop is not None: 

1873 continue 

1874 

1875 # Only collect references NOT guarded by `:default=` — a guarded 

1876 # reference is safe even when the capture is missing on some path. 

1877 refs: set[str] = set() 

1878 if state.action: 

1879 refs.update(_unguarded_captured_refs(state.action)) 

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

1881 refs.update(_unguarded_captured_refs(state.evaluate.source)) 

1882 if refs: 

1883 reference_map[state_name] = refs 

1884 

1885 if not reference_map: 

1886 return errors 

1887 

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

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

1890 for var_name in ref_vars: 

1891 if var_name not in capture_map: 

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

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

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

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

1896 if _has_sub_loop_state(fsm): 

1897 errors.append( 

1898 ValidationError( 

1899 message=( 

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

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

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

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

1904 f"state that produces this value." 

1905 ), 

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

1907 severity=ValidationSeverity.WARNING, 

1908 ) 

1909 ) 

1910 continue 

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

1912 errors.append( 

1913 ValidationError( 

1914 message=( 

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

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

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

1918 ), 

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

1920 severity=ValidationSeverity.ERROR, 

1921 ) 

1922 ) 

1923 continue 

1924 

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

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

1927 if not cap_states: 

1928 continue 

1929 

1930 # Group dominance check: do the capturing states collectively 

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

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

1933 bypass_path = _find_bypass_path_any(fsm, cap_states, ref_state_name) 

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

1935 

1936 if len(cap_states) == 1: 

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

1938 else: 

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

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

1941 

1942 errors.append( 

1943 ValidationError( 

1944 message=( 

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

1946 f"is captured by {captured_by} " 

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

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

1949 ), 

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

1951 severity=ValidationSeverity.WARNING, 

1952 ) 

1953 ) 

1954 

1955 return errors 

1956 

1957 

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

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

1960 

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

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

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

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

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

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

1967 

1968 Args: 

1969 fsm: The FSM loop to analyze 

1970 

1971 Returns: 

1972 Set of reachable state names 

1973 """ 

1974 reachable: set[str] = set() 

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

1976 if fsm.on_max_iterations is not None: 

1977 to_visit.append(fsm.on_max_iterations) 

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

1979 target = fsm.circuit.repeated_failure.on_repeated_failure 

1980 if target not in STALL_SPECIAL_TOKENS: 

1981 to_visit.append(target) 

1982 

1983 while to_visit: 

1984 current = to_visit.popleft() 

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

1986 continue 

1987 

1988 reachable.add(current) 

1989 state = fsm.states[current] 

1990 refs = state.get_referenced_states() 

1991 

1992 for ref in refs: 

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

1994 to_visit.append(ref) 

1995 

1996 return reachable 

1997 

1998 

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

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

2001 

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

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

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

2005 required-fields gate in :func:`load_and_validate` so "counted by the 

2006 verifier" stays in sync with "runnable by ll-loop validate". 

2007 

2008 When the raw YAML contains a ``from:`` key, inheritance is resolved first 

2009 (mirroring :func:`load_and_validate`) so pure context-override stubs whose 

2010 parent provides ``initial``/``states`` return True. Library fragments under 

2011 ``loops/lib/`` still return False — their parent chain also lacks ``initial``. 

2012 """ 

2013 try: 

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

2015 except (OSError, yaml.YAMLError): 

2016 return False 

2017 if not isinstance(data, dict): 

2018 return False 

2019 if "from" in data: 

2020 try: 

2021 data = resolve_inheritance(data, path.parent) 

2022 except Exception: 

2023 return False 

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

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

2026 

2027 

2028def load_and_validate( 

2029 path: Path, 

2030 raise_on_error: bool = True, 

2031) -> tuple[FSMLoop, list[ValidationError]]: 

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

2033 

2034 Args: 

2035 path: Path to the YAML file to load 

2036 raise_on_error: When True (default), raise ValueError on ERROR violations. 

2037 When False, return all violations (errors + warnings) without raising. 

2038 

2039 Returns: 

2040 When raise_on_error=True: (FSMLoop, list of WARNING-severity ValidationErrors) 

2041 When raise_on_error=False: (FSMLoop, list of all ValidationErrors sorted errors-first) 

2042 

2043 Raises: 

2044 FileNotFoundError: If the file doesn't exist 

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

2046 ValueError: If raise_on_error=True and validation fails (contains error details) 

2047 """ 

2048 if not path.exists(): 

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

2050 

2051 with open(path) as f: 

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

2053 

2054 if not isinstance(data, dict): 

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

2056 

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

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

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

2060 # result for the subsequent `resolve_fragments` pass. 

2061 data = resolve_inheritance(data, path.parent) 

2062 

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

2064 data = resolve_flow(data) 

2065 

2066 # Check required fields before parsing 

2067 missing = [] 

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

2069 if field not in data: 

2070 missing.append(field) 

2071 if "states" not in data: 

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

2073 

2074 if missing: 

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

2076 

2077 # Check for unknown top-level keys before parsing 

2078 unknown_key_warnings: list[ValidationError] = [] 

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

2080 if unknown: 

2081 unknown_key_warnings.append( 

2082 ValidationError( 

2083 path="<root>", 

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

2085 severity=ValidationSeverity.WARNING, 

2086 ) 

2087 ) 

2088 

2089 visibility_val = data.get("visibility") 

2090 if visibility_val is not None and visibility_val not in VALID_VISIBILITY: 

2091 unknown_key_warnings.append( 

2092 ValidationError( 

2093 path="visibility", 

2094 message=( 

2095 f"Invalid visibility: {visibility_val!r}. " 

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

2097 "Loop will be treated as 'public'." 

2098 ), 

2099 severity=ValidationSeverity.WARNING, 

2100 ) 

2101 ) 

2102 

2103 # Resolve fragment libraries before parsing into dataclass 

2104 data = resolve_fragments(data, path.parent) 

2105 

2106 # Parse into dataclass 

2107 fsm = FSMLoop.from_dict(data) 

2108 

2109 # Validate 

2110 errors = validate_fsm(fsm) 

2111 

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

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

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

2115 

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

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

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

2119 all_warnings = unknown_key_warnings + struct_warnings 

2120 

2121 if not raise_on_error: 

2122 return fsm, error_list + all_warnings 

2123 

2124 if error_list: 

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

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

2127 

2128 for warning in all_warnings: 

2129 logger.warning(str(warning)) 

2130 

2131 return fsm, all_warnings