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

765 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -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_steps", 

159 "on_max_steps", 

160 "max_iterations", 

161 "on_max_iterations", 

162 "max_edge_revisits", 

163 "backoff", 

164 "timeout", 

165 "default_timeout", 

166 "maintain", 

167 "llm", 

168 "on_handoff", 

169 "input_key", 

170 "required_inputs", 

171 "config", 

172 "category", 

173 "labels", 

174 "visibility", 

175 "commands", 

176 "targets", 

177 "circuit", 

178 "meta_self_eval_ok", 

179 "shared_state_ok", 

180 "partial_route_ok", 

181 "artifact_versioning", 

182 "artifact_versioning_ok", 

183 "generator_fix_ok", 

184 "import", 

185 "fragments", 

186 "from", 

187 "flow", 

188 "state_defs", 

189 } 

190) 

191 

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

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

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

195 

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

197VALID_PARAMETER_TYPES: frozenset[str] = frozenset( 

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

199) 

200 

201 

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

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

204 

205 Args: 

206 state_name: Name of the state containing this evaluator 

207 evaluate: The evaluator configuration to validate 

208 

209 Returns: 

210 List of validation errors found 

211 """ 

212 errors: list[ValidationError] = [] 

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

214 

215 # Check that evaluator type is recognized 

216 valid_types = set(EVALUATOR_REQUIRED_FIELDS.keys()) 

217 if evaluate.type not in valid_types: 

218 errors.append( 

219 ValidationError( 

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

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

222 path=path, 

223 ) 

224 ) 

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

226 

227 # Check required fields for evaluator type 

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

229 for field_name in required: 

230 value = getattr(evaluate, field_name, None) 

231 if value is None: 

232 errors.append( 

233 ValidationError( 

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

235 path=path, 

236 ) 

237 ) 

238 

239 # Validate operator if present 

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

241 errors.append( 

242 ValidationError( 

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

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

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

246 ) 

247 ) 

248 

249 # Validate convergence-specific fields 

250 if evaluate.type == "convergence": 

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

252 errors.append( 

253 ValidationError( 

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

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

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

257 ) 

258 ) 

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

260 if ( 

261 evaluate.tolerance is not None 

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

263 and evaluate.tolerance < 0 

264 ): 

265 errors.append( 

266 ValidationError( 

267 message="Tolerance cannot be negative", 

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

269 ) 

270 ) 

271 

272 # Validate llm_structured-specific fields 

273 if evaluate.type == "llm_structured": 

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

275 errors.append( 

276 ValidationError( 

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

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

279 ) 

280 ) 

281 

282 # Validate diff_stall-specific fields 

283 if evaluate.type == "diff_stall": 

284 if evaluate.max_stall < 1: 

285 errors.append( 

286 ValidationError( 

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

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

289 ) 

290 ) 

291 

292 # Validate action_stall-specific fields 

293 if evaluate.type == "action_stall": 

294 if evaluate.max_repeat < 1: 

295 errors.append( 

296 ValidationError( 

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

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

299 ) 

300 ) 

301 

302 return errors 

303 

304 

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

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

307 

308 Args: 

309 fsm: The FSM loop to validate 

310 

311 Returns: 

312 List of validation errors found 

313 """ 

314 errors: list[ValidationError] = [] 

315 

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

317 path = f"parameters.{param_name}" 

318 

319 if param_spec.type not in VALID_PARAMETER_TYPES: 

320 errors.append( 

321 ValidationError( 

322 message=( 

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

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

325 ), 

326 path=path, 

327 ) 

328 ) 

329 

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

331 errors.append( 

332 ValidationError( 

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

334 path=path, 

335 ) 

336 ) 

337 

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

339 errors.append( 

340 ValidationError( 

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

342 path=path, 

343 ) 

344 ) 

345 

346 return errors 

347 

348 

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

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

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

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

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

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

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

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

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

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

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

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

361 return None 

362 

363 

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

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

366 

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

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

369 

370 Args: 

371 fsm: The parent FSM loop 

372 loop_dir: Directory to resolve child loop paths from 

373 

374 Returns: 

375 List of validation errors found 

376 """ 

377 errors: list[ValidationError] = [] 

378 

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

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

381 continue 

382 

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

384 try: 

385 from little_loops.cli.loop._helpers import resolve_loop_path 

386 

387 loop_path = resolve_loop_path(state.loop, loop_dir) 

388 child_fsm, _ = load_and_validate(loop_path) 

389 except Exception: 

390 continue 

391 

392 if not child_fsm.parameters: 

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

394 

395 path = f"states.{state_name}" 

396 

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

398 for key in state.with_: 

399 if key not in child_fsm.parameters: 

400 errors.append( 

401 ValidationError( 

402 message=( 

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

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

405 ), 

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

407 ) 

408 ) 

409 

410 # Required parameters not bound 

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

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

413 errors.append( 

414 ValidationError( 

415 message=( 

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

417 f"is not bound in 'with'" 

418 ), 

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

420 ) 

421 ) 

422 

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

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

425 if param_name not in child_fsm.parameters: 

426 continue 

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

428 continue 

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

430 if type_error: 

431 errors.append( 

432 ValidationError( 

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

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

435 ) 

436 ) 

437 

438 return errors 

439 

440 

441def _validate_loop_references(fsm: FSMLoop, loop_dir: Path) -> list[ValidationError]: 

442 """Validate that every state's loop: reference resolves to an actual loop file. 

443 

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

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

446 """ 

447 errors: list[ValidationError] = [] 

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

449 if state.loop is None: 

450 continue 

451 # Skip dynamically interpolated loop names — they can only be checked at runtime 

452 if "${" in state.loop: 

453 continue 

454 try: 

455 from little_loops.cli.loop._helpers import resolve_loop_path 

456 

457 resolve_loop_path(state.loop, loop_dir) 

458 except FileNotFoundError: 

459 errors.append( 

460 ValidationError( 

461 message=f"Loop reference '{state.loop}' does not resolve to any file.", 

462 path=f"states.{state_name}.loop", 

463 severity=ValidationSeverity.WARNING, 

464 ) 

465 ) 

466 return errors 

467 

468 

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

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

471 

472 Called from load_and_validate (not validate_fsm) because fragment parameters 

473 are populated by resolve_fragments which runs before dataclass parsing. 

474 

475 Args: 

476 fsm: The FSM loop to validate 

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

478 _validate_with_bindings) 

479 

480 Returns: 

481 List of validation errors found 

482 """ 

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

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

485 

486 errors: list[ValidationError] = [] 

487 

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

489 if not state.fragment_parameters: 

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

491 

492 path = f"states.{state_name}" 

493 

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

495 for key in state.fragment_bindings: 

496 if key not in state.fragment_parameters: 

497 errors.append( 

498 ValidationError( 

499 message=( 

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

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

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

503 ), 

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

505 ) 

506 ) 

507 

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

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

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

511 if param_name in RUNNER_INJECTED: 

512 continue # Available at runtime; not a static error 

513 errors.append( 

514 ValidationError( 

515 message=( 

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

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

518 ), 

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

520 ) 

521 ) 

522 

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

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

525 if param_name not in state.fragment_parameters: 

526 continue 

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

528 continue 

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

530 if type_error: 

531 errors.append( 

532 ValidationError( 

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

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

535 ) 

536 ) 

537 

538 return errors 

539 

540 

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

542 """Validate state action configuration. 

543 

544 Args: 

545 state_name: Name of the state to validate 

546 state: The state configuration to validate 

547 

548 Returns: 

549 List of validation errors found 

550 """ 

551 errors: list[ValidationError] = [] 

552 path = f"states.{state_name}" 

553 

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

555 if state.append_to_messages is not None: 

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

557 errors.append( 

558 ValidationError( 

559 message=( 

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

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

562 ), 

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

564 ) 

565 ) 

566 

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

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

569 errors.append( 

570 ValidationError( 

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

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

573 severity=ValidationSeverity.WARNING, 

574 ) 

575 ) 

576 

577 # params field is only valid for mcp_tool states 

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

579 errors.append( 

580 ValidationError( 

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

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

583 ) 

584 ) 

585 

586 # loop and action are mutually exclusive 

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

588 errors.append( 

589 ValidationError( 

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

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

592 path=f"{path}", 

593 ) 

594 ) 

595 

596 # with: requires loop: to be set 

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

598 errors.append( 

599 ValidationError( 

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

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

602 ) 

603 ) 

604 

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

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

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

608 errors.append( 

609 ValidationError( 

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

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

612 ) 

613 ) 

614 if state.learning.max_retries < 0: 

615 errors.append( 

616 ValidationError( 

617 message=( 

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

619 ), 

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

621 ) 

622 ) 

623 if state.on_yes is None: 

624 errors.append( 

625 ValidationError( 

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

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

628 ) 

629 ) 

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

631 errors.append( 

632 ValidationError( 

633 message=( 

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

635 "(target for refuted / retries_exhausted)" 

636 ), 

637 path=f"{path}", 

638 ) 

639 ) 

640 

641 # with: and context_passthrough are mutually exclusive 

642 if state.with_ and state.context_passthrough: 

643 errors.append( 

644 ValidationError( 

645 message=( 

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

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

648 "for legacy bulk passthrough, not both" 

649 ), 

650 path=f"{path}", 

651 ) 

652 ) 

653 

654 return errors 

655 

656 

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

658 """Validate state routing configuration. 

659 

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

661 

662 Args: 

663 state_name: Name of the state to validate 

664 state: The state configuration to validate 

665 

666 Returns: 

667 List of validation errors/warnings found 

668 """ 

669 errors: list[ValidationError] = [] 

670 path = f"states.{state_name}" 

671 

672 has_shorthand = ( 

673 state.on_yes is not None 

674 or state.on_no is not None 

675 or state.on_error is not None 

676 or state.on_partial is not None 

677 or state.on_blocked is not None 

678 or bool(state.extra_routes) 

679 ) 

680 has_route = state.route is not None 

681 

682 # Warn about conflicting definitions 

683 if has_shorthand and has_route: 

684 errors.append( 

685 ValidationError( 

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

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

688 path=path, 

689 severity=ValidationSeverity.WARNING, 

690 ) 

691 ) 

692 

693 # Check for no valid transition definition 

694 has_next = state.next is not None 

695 has_terminal = state.terminal 

696 has_loop = state.loop is not None 

697 

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

699 errors.append( 

700 ValidationError( 

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

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

703 path=path, 

704 ) 

705 ) 

706 

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

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

709 errors.append( 

710 ValidationError( 

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

712 path=path, 

713 ) 

714 ) 

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

716 errors.append( 

717 ValidationError( 

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

719 path=path, 

720 ) 

721 ) 

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

723 errors.append( 

724 ValidationError( 

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

726 path=path, 

727 ) 

728 ) 

729 

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

731 if state.retryable_exit_codes is not None: 

732 if state.on_error is None: 

733 errors.append( 

734 ValidationError( 

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

736 path=path, 

737 ) 

738 ) 

739 for code in state.retryable_exit_codes: 

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

741 errors.append( 

742 ValidationError( 

743 message=( 

744 f"'retryable_exit_codes' entries must be positive " 

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

746 ), 

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

748 ) 

749 ) 

750 break 

751 

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

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

754 errors.append( 

755 ValidationError( 

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

757 path=path, 

758 ) 

759 ) 

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

761 errors.append( 

762 ValidationError( 

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

764 path=path, 

765 ) 

766 ) 

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

768 errors.append( 

769 ValidationError( 

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

771 path=path, 

772 ) 

773 ) 

774 if ( 

775 state.rate_limit_backoff_base_seconds is not None 

776 and state.rate_limit_backoff_base_seconds < 1 

777 ): 

778 errors.append( 

779 ValidationError( 

780 message=( 

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

782 f"got {state.rate_limit_backoff_base_seconds}" 

783 ), 

784 path=path, 

785 ) 

786 ) 

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

788 errors.append( 

789 ValidationError( 

790 message=( 

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

792 f"got {state.rate_limit_max_wait_seconds}" 

793 ), 

794 path=path, 

795 ) 

796 ) 

797 if state.rate_limit_long_wait_ladder is not None: 

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

799 errors.append( 

800 ValidationError( 

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

802 path=path, 

803 ) 

804 ) 

805 else: 

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

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

808 errors.append( 

809 ValidationError( 

810 message=( 

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

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

813 ), 

814 path=path, 

815 ) 

816 ) 

817 

818 # Validate throttle config when present 

819 if state.throttle is not None: 

820 t = state.throttle 

821 fields = { 

822 "normal_max": t.normal_max, 

823 "warn_max": t.warn_max, 

824 "hard_max": t.hard_max, 

825 } 

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

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

828 errors.append( 

829 ValidationError( 

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

831 path=path, 

832 ) 

833 ) 

834 # Enforce ordering when all three are set 

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

836 errors.append( 

837 ValidationError( 

838 message=( 

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

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

841 ), 

842 path=path, 

843 ) 

844 ) 

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

846 errors.append( 

847 ValidationError( 

848 message=( 

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

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

851 ), 

852 path=path, 

853 ) 

854 ) 

855 

856 return errors 

857 

858 

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

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

861 

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

863 end with a .yaml extension. 

864 """ 

865 errors: list[ValidationError] = [] 

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

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

868 errors.append( 

869 ValidationError( 

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

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

872 ) 

873 ) 

874 return errors 

875 

876 

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

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

879 

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

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

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

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

884 itself can execute. 

885 

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

887 failure terminals continue to load, and test_terminal_only_state_valid 

888 (which filters by ERROR) passes without modification. 

889 """ 

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

891 errors: list[ValidationError] = [] 

892 

893 terminal_states = fsm.get_terminal_states() 

894 failure_terminals = terminal_states & FAILURE_TERMINAL_NAMES 

895 

896 for ft_name in failure_terminals: 

897 has_diagnostic_predecessor = False 

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

899 if state_name == ft_name: 

900 continue 

901 if ft_name in state.get_referenced_states(): 

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

903 has_diagnostic_predecessor = True 

904 break 

905 

906 if not has_diagnostic_predecessor: 

907 errors.append( 

908 ValidationError( 

909 message=( 

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

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

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

913 f"to '{ft_name}'." 

914 ), 

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

916 severity=ValidationSeverity.WARNING, 

917 ) 

918 ) 

919 

920 return errors 

921 

922 

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

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

925 

926 Performs comprehensive validation: 

927 - Initial state exists 

928 - All referenced states exist 

929 - At least one terminal state 

930 - Evaluator configurations are valid 

931 - Routing configurations are valid 

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

933 

934 Args: 

935 fsm: The FSM loop to validate 

936 

937 Returns: 

938 List of validation errors (empty if valid) 

939 """ 

940 errors: list[ValidationError] = [] 

941 defined_states = fsm.get_all_state_names() 

942 

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

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

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

946 if not fsm.description: 

947 errors.append( 

948 ValidationError( 

949 path="<root>", 

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

951 severity=ValidationSeverity.WARNING, 

952 ) 

953 ) 

954 

955 # Validate parameters block 

956 errors.extend(_validate_parameters(fsm)) 

957 

958 # Validate targets block (ENH-1552) 

959 errors.extend(_validate_targets(fsm)) 

960 

961 # Check initial state exists 

962 if fsm.initial not in defined_states: 

963 errors.append( 

964 ValidationError( 

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

966 path="initial", 

967 ) 

968 ) 

969 

970 # Check at least one terminal state 

971 terminal_states = fsm.get_terminal_states() 

972 if not terminal_states: 

973 errors.append( 

974 ValidationError( 

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

976 path="states", 

977 ) 

978 ) 

979 

980 # Validate each state 

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

982 # Check all referenced states exist 

983 refs = state.get_referenced_states() 

984 for ref in refs: 

985 # $current is a special token for retry 

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

987 errors.append( 

988 ValidationError( 

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

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

991 ) 

992 ) 

993 

994 # Validate action configuration 

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

996 

997 # Validate evaluator if present 

998 if state.evaluate is not None: 

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

1000 

1001 # Validate routing configuration 

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

1003 

1004 # Check numeric field ranges 

1005 if fsm.max_steps <= 0: 

1006 errors.append( 

1007 ValidationError( 

1008 message=f"max_steps must be > 0, got {fsm.max_steps}", 

1009 path="max_steps", 

1010 ) 

1011 ) 

1012 if fsm.max_iterations is not None and fsm.max_iterations <= 0: 

1013 errors.append( 

1014 ValidationError( 

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

1016 path="max_iterations", 

1017 ) 

1018 ) 

1019 if fsm.max_edge_revisits <= 0: 

1020 errors.append( 

1021 ValidationError( 

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

1023 path="max_edge_revisits", 

1024 ) 

1025 ) 

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

1027 errors.append( 

1028 ValidationError( 

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

1030 path="backoff", 

1031 ) 

1032 ) 

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

1034 errors.append( 

1035 ValidationError( 

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

1037 path="timeout", 

1038 ) 

1039 ) 

1040 if fsm.llm.max_tokens <= 0: 

1041 errors.append( 

1042 ValidationError( 

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

1044 path="llm.max_tokens", 

1045 ) 

1046 ) 

1047 if fsm.llm.timeout <= 0: 

1048 errors.append( 

1049 ValidationError( 

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

1051 path="llm.timeout", 

1052 ) 

1053 ) 

1054 

1055 # Check for unreachable states (warning only) 

1056 reachable = _find_reachable_states(fsm) 

1057 unreachable = defined_states - reachable 

1058 for state_name in unreachable: 

1059 errors.append( 

1060 ValidationError( 

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

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

1063 severity=ValidationSeverity.WARNING, 

1064 ) 

1065 ) 

1066 

1067 errors.extend(_validate_failure_terminal_action(fsm)) 

1068 

1069 errors.extend(_validate_meta_loop_evaluation(fsm)) 

1070 

1071 errors.extend(_validate_input_key_without_guard(fsm)) 

1072 

1073 errors.extend(_validate_artifact_isolation(fsm)) 

1074 

1075 errors.extend(_validate_harness_multimodal_evaluator_blind_spot(fsm)) 

1076 

1077 errors.extend(_validate_partial_route_dead_end(fsm)) 

1078 

1079 errors.extend(_validate_artifact_overwrite(fsm)) 

1080 

1081 errors.extend(_validate_generator_fix_discipline(fsm)) 

1082 

1083 errors.extend(_validate_classify_route_default(fsm)) 

1084 

1085 errors.extend(_validate_zero_retry_counter(fsm)) 

1086 

1087 errors.extend(_validate_on_max_steps(fsm, defined_states)) 

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

1089 

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

1091 

1092 errors.extend(_validate_progress_paths_isolation(fsm)) 

1093 

1094 errors.extend(_validate_capture_reachability(fsm)) 

1095 

1096 return errors 

1097 

1098 

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

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

1101 

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

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

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

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

1106 3. Any state action references yaml_state_editor or replace_action 

1107 """ 

1108 # Condition 2: imports lib/benchmark.yaml 

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

1110 return True 

1111 # Conditions 1 and 3: scan action strings 

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

1113 if state.action is None: 

1114 continue 

1115 for pattern in _META_LOOP_ACTION_PATTERNS: 

1116 if pattern.search(state.action): 

1117 return True 

1118 for token in _META_LOOP_ACTION_TOKENS: 

1119 if token in state.action: 

1120 return True 

1121 return False 

1122 

1123 

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

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

1126 

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

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

1129 

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

1131 """ 

1132 errors: list[ValidationError] = [] 

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

1134 return errors 

1135 

1136 # Collect all evaluator types used across all states 

1137 evaluator_types: set[str] = set() 

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

1139 if state.evaluate is not None: 

1140 evaluator_types.add(state.evaluate.type) 

1141 

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

1143 if not evaluator_types & NON_LLM_EVALUATOR_TYPES: 

1144 errors.append( 

1145 ValidationError( 

1146 message=( 

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

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

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

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

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

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

1153 "loop top-level." 

1154 ), 

1155 path="<root>", 

1156 severity=ValidationSeverity.ERROR, 

1157 ) 

1158 ) 

1159 

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

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

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

1163 errors.append( 

1164 ValidationError( 

1165 message=( 

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

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

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

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

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

1171 ), 

1172 path="<root>", 

1173 severity=ValidationSeverity.WARNING, 

1174 ) 

1175 ) 

1176 

1177 return errors 

1178 

1179 

1180# Regex patterns for detecting counter-increment actions. 

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

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

1183_COUNTER_INCREMENT_RE = re.compile( 

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

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

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

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

1188) 

1189 

1190 

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

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

1193 

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

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

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

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

1198 """ 

1199 errors: list[ValidationError] = [] 

1200 

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

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

1203 continue 

1204 

1205 ev = state.evaluate 

1206 if ev.type != "output_numeric": 

1207 continue 

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

1209 continue 

1210 

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

1212 try: 

1213 target = float(ev.target) 

1214 except (ValueError, TypeError): 

1215 continue 

1216 

1217 if not _is_counter_action(state.action): 

1218 continue 

1219 

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

1221 op_fn = _NUMERIC_OPERATORS.get(ev.operator) 

1222 if op_fn is None: 

1223 continue 

1224 

1225 if not op_fn(1.0, target): 

1226 suggested_target = _suggested_target(ev.operator, target) 

1227 errors.append( 

1228 ValidationError( 

1229 message=( 

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

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

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

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

1234 ), 

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

1236 severity=ValidationSeverity.WARNING, 

1237 ) 

1238 ) 

1239 

1240 return errors 

1241 

1242 

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

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

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

1246 

1247 

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

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

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

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

1252 return str(int(target) + 1) 

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

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

1255 return "1" 

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

1257 return str(int(target) + 1) 

1258 

1259 

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

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

1262 

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

1264 producing verdicts based on incomplete information. The output_contains 

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

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

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

1268 harness modification. 

1269 

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

1271 """ 

1272 errors: list[ValidationError] = [] 

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

1274 return errors 

1275 

1276 terminal_states = fsm.get_terminal_states() 

1277 

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

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

1280 continue 

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

1282 continue 

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

1284 continue 

1285 if state.on_yes not in terminal_states: 

1286 continue 

1287 errors.append( 

1288 ValidationError( 

1289 message=( 

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

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

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

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

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

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

1296 ), 

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

1298 severity=ValidationSeverity.WARNING, 

1299 ) 

1300 ) 

1301 

1302 return errors 

1303 

1304 

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

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

1307 

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

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

1310 place where loop YAMLs directly encode artifact paths. 

1311 """ 

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

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

1314 if not state.action: 

1315 continue 

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

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

1318 return findings 

1319 

1320 

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

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

1323 

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

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

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

1327 """ 

1328 if fsm.input_key == "input": 

1329 return [] 

1330 if fsm.required_inputs: 

1331 return [] 

1332 return [ 

1333 ValidationError( 

1334 message=( 

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

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

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

1338 f"abort when no input is provided." 

1339 ), 

1340 path="required_inputs", 

1341 severity=ValidationSeverity.WARNING, 

1342 ) 

1343 ] 

1344 

1345 

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

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

1348 

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

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

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

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

1353 repeated invocations). 

1354 

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

1356 intentionally share state across runs. 

1357 """ 

1358 if fsm.shared_state_ok: 

1359 return [] 

1360 errors: list[ValidationError] = [] 

1361 for state_name, path in _find_shared_tmp_writes(fsm): 

1362 errors.append( 

1363 ValidationError( 

1364 message=( 

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

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

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

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

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

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

1371 ), 

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

1373 severity=ValidationSeverity.WARNING, 

1374 ) 

1375 ) 

1376 return errors 

1377 

1378 

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

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

1381 

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

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

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

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

1386 """ 

1387 if state.evaluate is None: 

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

1389 action_type = state.action_type 

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

1391 return True 

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

1393 return True 

1394 return False 

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

1396 

1397 

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

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

1400 

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

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

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

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

1405 

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

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

1408 """ 

1409 if fsm.partial_route_ok: 

1410 return [] 

1411 errors: list[ValidationError] = [] 

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

1413 if not _is_llm_judged(state): 

1414 continue 

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

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

1417 continue 

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

1419 if state.on_yes is None: 

1420 continue 

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

1422 if not missing: 

1423 continue 

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

1425 errors.append( 

1426 ValidationError( 

1427 message=( 

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

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

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

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

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

1433 "if intentional. (ENH-1917)" 

1434 ), 

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

1436 severity=ValidationSeverity.WARNING, 

1437 ) 

1438 ) 

1439 return errors 

1440 

1441 

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

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

1444 

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

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

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

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

1449 

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

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

1452 by task). 

1453 """ 

1454 if fsm.artifact_versioning or fsm.artifact_versioning_ok: 

1455 return [] 

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

1457 return [] 

1458 

1459 errors: list[ValidationError] = [] 

1460 

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

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

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

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

1465 continue 

1466 # Skip sub-loop delegation states 

1467 if state.action_type == "loop": 

1468 continue 

1469 action = state.action 

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

1471 import re 

1472 

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

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

1475 artifact_refs = set() 

1476 for pattern in ( 

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

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

1479 ): 

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

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

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

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

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

1485 if artifact_refs: 

1486 writers[state_name] = artifact_refs 

1487 

1488 if not writers: 

1489 return [] 

1490 

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

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

1493 for state_name in writers: 

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

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

1496 visited: set[str] = set() 

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

1498 while to_visit: 

1499 target = to_visit.pop() 

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

1501 continue 

1502 visited.add(target) 

1503 if target == state_name: 

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

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

1506 errors.append( 

1507 ValidationError( 

1508 message=( 

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

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

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

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

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

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

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

1516 ), 

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

1518 severity=ValidationSeverity.WARNING, 

1519 ) 

1520 ) 

1521 break 

1522 target_state = fsm.states.get(target) 

1523 if target_state is not None: 

1524 target_refs = target_state.get_referenced_states() 

1525 for r in target_refs: 

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

1527 to_visit.append(r) 

1528 

1529 return errors 

1530 

1531 

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

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

1534 

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

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

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

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

1539 

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

1541 post-processing cases. 

1542 """ 

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

1544 return [] 

1545 

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

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

1548 

1549 _PATH_PATTERNS = ( 

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

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

1552 ) 

1553 

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

1555 paths: set[str] = set() 

1556 for pat in _PATH_PATTERNS: 

1557 for m in pat.finditer(action): 

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

1559 return paths 

1560 

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

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

1563 

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

1565 if not state.action: 

1566 continue 

1567 action = state.action 

1568 paths = _extract_paths(action) 

1569 if not paths: 

1570 continue 

1571 action_type = state.action_type 

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

1573 shell_targets[state_name] = paths 

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

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

1576 generator_targets[state_name] = paths 

1577 

1578 errors: list[ValidationError] = [] 

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

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

1581 overlap = gen_paths & shell_paths 

1582 if overlap: 

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

1584 errors.append( 

1585 ValidationError( 

1586 message=( 

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

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

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

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

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

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

1593 ), 

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

1595 severity=ValidationSeverity.WARNING, 

1596 ) 

1597 ) 

1598 

1599 return errors 

1600 

1601 

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

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

1604 

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

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

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

1608 

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

1610 dead-end on an unlisted token is intentional. 

1611 """ 

1612 if fsm.partial_route_ok: 

1613 return [] 

1614 errors: list[ValidationError] = [] 

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

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

1617 continue 

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

1619 continue 

1620 errors.append( 

1621 ValidationError( 

1622 message=( 

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

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

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

1626 ), 

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

1628 severity=ValidationSeverity.WARNING, 

1629 ) 

1630 ) 

1631 return errors 

1632 

1633 

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

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

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

1637 ev = state.evaluate 

1638 if ev is None: 

1639 continue 

1640 # Check string fields that may interpolate captured values 

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

1642 if isinstance(ev.target, str): 

1643 candidates.append(ev.target) 

1644 for field_val in candidates: 

1645 if not field_val: 

1646 continue 

1647 for name in capture_names: 

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

1649 return True 

1650 return False 

1651 

1652 

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

1654 """Validate the top-level `on_max_steps` field (BUG-2204). 

1655 

1656 Checks that the named state exists when `on_max_steps` is set. 

1657 """ 

1658 errors: list[ValidationError] = [] 

1659 if fsm.on_max_steps is None: 

1660 return errors 

1661 if fsm.on_max_steps not in defined_states: 

1662 errors.append( 

1663 ValidationError( 

1664 message=( 

1665 f"on_max_steps references unknown state " 

1666 f"'{fsm.on_max_steps}' (must be a declared state)" 

1667 ), 

1668 path="on_max_steps", 

1669 ) 

1670 ) 

1671 return errors 

1672 

1673 

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

1675 """Validate the top-level `on_max_iterations` field (BUG-2204: iteration-cap summary state). 

1676 

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

1678 """ 

1679 errors: list[ValidationError] = [] 

1680 if fsm.on_max_iterations is None: 

1681 return errors 

1682 if fsm.on_max_iterations not in defined_states: 

1683 errors.append( 

1684 ValidationError( 

1685 message=( 

1686 f"on_max_iterations references unknown state " 

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

1688 ), 

1689 path="on_max_iterations", 

1690 ) 

1691 ) 

1692 return errors 

1693 

1694 

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

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

1697 

1698 Checks: 

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

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

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

1702 """ 

1703 errors: list[ValidationError] = [] 

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

1705 return errors 

1706 

1707 rf = fsm.circuit.repeated_failure 

1708 if rf.window < 1: 

1709 errors.append( 

1710 ValidationError( 

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

1712 path="circuit.repeated_failure.window", 

1713 ) 

1714 ) 

1715 

1716 if rf.recurrent_window is not None and rf.recurrent_window < 2: 

1717 errors.append( 

1718 ValidationError( 

1719 message=( 

1720 f"circuit.repeated_failure.recurrent_window must be >= 2, " 

1721 f"got {rf.recurrent_window}" 

1722 ), 

1723 path="circuit.repeated_failure.recurrent_window", 

1724 ) 

1725 ) 

1726 

1727 target = rf.on_repeated_failure 

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

1729 errors.append( 

1730 ValidationError( 

1731 message=( 

1732 f"circuit.repeated_failure.on_repeated_failure references " 

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

1734 f'the literal "abort")' 

1735 ), 

1736 path="circuit.repeated_failure.on_repeated_failure", 

1737 ) 

1738 ) 

1739 

1740 return errors 

1741 

1742 

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

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

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

1746 

1747 

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

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

1750 return _INTERPOLATION_PREFIX_RE.sub("", path) 

1751 

1752 

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

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

1755 

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

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

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

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

1760 """ 

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

1762 return [] 

1763 rf = fsm.circuit.repeated_failure 

1764 if not rf.progress_paths: 

1765 return [] 

1766 

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

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

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

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

1771 active_watched = watched - excluded 

1772 if not active_watched: 

1773 return [] 

1774 

1775 errors: list[ValidationError] = [] 

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

1777 if not state.action: 

1778 continue 

1779 for path_fragment in active_watched: 

1780 if path_fragment in state.action: 

1781 errors.append( 

1782 ValidationError( 

1783 message=( 

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

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

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

1787 "silently disabling stall detection. Move it to " 

1788 "circuit.repeated_failure.exclude_paths to separate " 

1789 "bookkeeping files from real progress signals." 

1790 ), 

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

1792 severity=ValidationSeverity.WARNING, 

1793 ) 

1794 ) 

1795 return errors 

1796 

1797 

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

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

1800 

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

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

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

1804 reachable from the initial state. 

1805 

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

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

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

1809 

1810 Args: 

1811 fsm: The FSM loop to analyze 

1812 dominators: Names of the states that should collectively dominate 

1813 dominated: Name of the state that should be dominated 

1814 

1815 Returns: 

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

1817 """ 

1818 if dominated in dominators: 

1819 return True 

1820 if dominated not in fsm.states: 

1821 return False 

1822 

1823 visited: set[str] = set() 

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

1825 

1826 while to_visit: 

1827 current = to_visit.popleft() 

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

1829 continue 

1830 if current in dominators: 

1831 continue # Block this node (simulate removal) 

1832 

1833 visited.add(current) 

1834 

1835 if current == dominated: 

1836 # Reached dominated without going through any dominator 

1837 return False 

1838 

1839 state = fsm.states[current] 

1840 for ref in state.get_referenced_states(): 

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

1842 to_visit.append(ref) 

1843 

1844 # Dominated not reachable without the dominators → they dominate 

1845 return True 

1846 

1847 

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

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

1850 

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

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

1853 """ 

1854 if dominator not in fsm.states: 

1855 return False 

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

1857 

1858 

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

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

1861 

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

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

1864 :func:`_dominated_by_any` returns False). 

1865 """ 

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

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

1868 visited: set[str] = set() 

1869 

1870 while to_visit: 

1871 current = to_visit.popleft() 

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

1873 continue 

1874 if current in dominators: 

1875 continue 

1876 

1877 visited.add(current) 

1878 

1879 if current == dominated: 

1880 # Reconstruct path 

1881 path = [dominated] 

1882 while path[-1] in parent: 

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

1884 path.reverse() 

1885 return path 

1886 

1887 state = fsm.states[current] 

1888 for ref in state.get_referenced_states(): 

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

1890 if ref not in parent: 

1891 parent[ref] = current 

1892 to_visit.append(ref) 

1893 

1894 return [] 

1895 

1896 

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

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

1899 

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

1901 """ 

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

1903 

1904 

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

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

1907 

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

1909 """ 

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

1911 

1912 

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

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

1915 

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

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

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

1919 

1920 Emits: 

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

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

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

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

1925 """ 

1926 errors: list[ValidationError] = [] 

1927 

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

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

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

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

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

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

1934 if state.capture: 

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

1936 

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

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

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

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

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

1942 if state.loop is not None: 

1943 continue 

1944 

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

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

1947 refs: set[str] = set() 

1948 if state.action: 

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

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

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

1952 if refs: 

1953 reference_map[state_name] = refs 

1954 

1955 if not reference_map: 

1956 return errors 

1957 

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

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

1960 for var_name in ref_vars: 

1961 if var_name not in capture_map: 

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

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

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

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

1966 if _has_sub_loop_state(fsm): 

1967 errors.append( 

1968 ValidationError( 

1969 message=( 

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

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

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

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

1974 f"state that produces this value." 

1975 ), 

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

1977 severity=ValidationSeverity.WARNING, 

1978 ) 

1979 ) 

1980 continue 

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

1982 errors.append( 

1983 ValidationError( 

1984 message=( 

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

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

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

1988 ), 

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

1990 severity=ValidationSeverity.ERROR, 

1991 ) 

1992 ) 

1993 continue 

1994 

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

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

1997 if not cap_states: 

1998 continue 

1999 

2000 # Group dominance check: do the capturing states collectively 

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

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

2003 bypass_path = _find_bypass_path_any(fsm, cap_states, ref_state_name) 

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

2005 

2006 if len(cap_states) == 1: 

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

2008 else: 

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

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

2011 

2012 errors.append( 

2013 ValidationError( 

2014 message=( 

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

2016 f"is captured by {captured_by} " 

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

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

2019 ), 

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

2021 severity=ValidationSeverity.WARNING, 

2022 ) 

2023 ) 

2024 

2025 return errors 

2026 

2027 

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

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

2030 

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

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

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

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

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

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

2037 

2038 Args: 

2039 fsm: The FSM loop to analyze 

2040 

2041 Returns: 

2042 Set of reachable state names 

2043 """ 

2044 reachable: set[str] = set() 

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

2046 if fsm.on_max_steps is not None: 

2047 to_visit.append(fsm.on_max_steps) 

2048 if fsm.on_max_iterations is not None: 

2049 to_visit.append(fsm.on_max_iterations) 

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

2051 target = fsm.circuit.repeated_failure.on_repeated_failure 

2052 if target not in STALL_SPECIAL_TOKENS: 

2053 to_visit.append(target) 

2054 

2055 while to_visit: 

2056 current = to_visit.popleft() 

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

2058 continue 

2059 

2060 reachable.add(current) 

2061 state = fsm.states[current] 

2062 refs = state.get_referenced_states() 

2063 

2064 for ref in refs: 

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

2066 to_visit.append(ref) 

2067 

2068 return reachable 

2069 

2070 

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

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

2073 

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

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

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

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

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

2079 

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

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

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

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

2084 """ 

2085 try: 

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

2087 except (OSError, yaml.YAMLError): 

2088 return False 

2089 if not isinstance(data, dict): 

2090 return False 

2091 if "from" in data: 

2092 try: 

2093 data = resolve_inheritance(data, path.parent) 

2094 except Exception: 

2095 return False 

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

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

2098 

2099 

2100def load_and_validate( 

2101 path: Path, 

2102 raise_on_error: bool = True, 

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

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

2105 

2106 Args: 

2107 path: Path to the YAML file to load 

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

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

2110 

2111 Returns: 

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

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

2114 

2115 Raises: 

2116 FileNotFoundError: If the file doesn't exist 

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

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

2119 """ 

2120 if not path.exists(): 

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

2122 

2123 with open(path) as f: 

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

2125 

2126 if not isinstance(data, dict): 

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

2128 

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

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

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

2132 # result for the subsequent `resolve_fragments` pass. 

2133 data = resolve_inheritance(data, path.parent) 

2134 

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

2136 data = resolve_flow(data) 

2137 

2138 # Check required fields before parsing 

2139 missing = [] 

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

2141 if field not in data: 

2142 missing.append(field) 

2143 if "states" not in data: 

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

2145 

2146 if missing: 

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

2148 

2149 # Check for unknown top-level keys before parsing 

2150 unknown_key_warnings: list[ValidationError] = [] 

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

2152 if unknown: 

2153 unknown_key_warnings.append( 

2154 ValidationError( 

2155 path="<root>", 

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

2157 severity=ValidationSeverity.WARNING, 

2158 ) 

2159 ) 

2160 

2161 visibility_val = data.get("visibility") 

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

2163 unknown_key_warnings.append( 

2164 ValidationError( 

2165 path="visibility", 

2166 message=( 

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

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

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

2170 ), 

2171 severity=ValidationSeverity.WARNING, 

2172 ) 

2173 ) 

2174 

2175 # Resolve fragment libraries before parsing into dataclass 

2176 data = resolve_fragments(data, path.parent) 

2177 

2178 # Parse into dataclass 

2179 fsm = FSMLoop.from_dict(data) 

2180 

2181 # Validate 

2182 errors = validate_fsm(fsm) 

2183 

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

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

2186 errors.extend(_validate_loop_references(fsm, path.parent)) 

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

2188 

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

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

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

2192 all_warnings = unknown_key_warnings + struct_warnings 

2193 

2194 if not raise_on_error: 

2195 return fsm, error_list + all_warnings 

2196 

2197 if error_list: 

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

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

2200 

2201 for warning in all_warnings: 

2202 logger.warning(str(warning)) 

2203 

2204 return fsm, all_warnings