Coverage for little_loops / fsm / route_table.py: 0%

372 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -0500

1"""Route table extraction, rendering, parsing, and application for FSM loops.""" 

2 

3from __future__ import annotations 

4 

5import csv 

6import io 

7import os 

8import re 

9import subprocess 

10import tempfile 

11from dataclasses import dataclass, field 

12from pathlib import Path 

13from typing import TYPE_CHECKING, Any 

14 

15if TYPE_CHECKING: 

16 from little_loops.fsm.policy_rules import Rule 

17 from little_loops.fsm.schema import FSMLoop 

18 

19# Ordered standard verdict labels for column display 

20_STANDARD_VERDICTS = ["yes", "no", "error", "partial", "blocked", "next", "default"] 

21 

22# Mapping from verdict label → YAML field name (shorthand mode) 

23_VERDICT_TO_FIELD: dict[str, str] = { 

24 "yes": "on_yes", 

25 "no": "on_no", 

26 "error": "on_error", 

27 "partial": "on_partial", 

28 "blocked": "on_blocked", 

29 "next": "next", 

30} 

31 

32EMPTY_CELL = "—" 

33 

34 

35@dataclass 

36class ParsedTable: 

37 """Result of parsing an edited route table.""" 

38 

39 matrix: dict[str, dict[str, str]] 

40 new_stubs: list[str] 

41 deleted_states: list[str] 

42 

43 

44class RouteTableExtractor: 

45 """Extract FSM routing into a state×verdict matrix.""" 

46 

47 @staticmethod 

48 def extract(fsm: FSMLoop) -> dict[str, dict[str, str]]: 

49 """Return {state: {verdict: target}} for all states in fsm.""" 

50 matrix: dict[str, dict[str, str]] = {} 

51 for name, state in fsm.states.items(): 

52 row: dict[str, str] = {} 

53 # Shorthand fields 

54 for verdict, target in [ 

55 ("yes", state.on_yes), 

56 ("no", state.on_no), 

57 ("error", state.on_error), 

58 ("partial", state.on_partial), 

59 ("blocked", state.on_blocked), 

60 ("next", state.next), 

61 ]: 

62 if target: 

63 row[verdict] = target 

64 # route: block fields (may overlap shorthands; route takes precedence at runtime) 

65 if state.route: 

66 for verdict, target in state.route.routes.items(): 

67 row[verdict] = target 

68 if state.route.default: 

69 row["default"] = state.route.default 

70 # extra_routes: on_<custom> → stored with prefix stripped 

71 for verdict, target in state.extra_routes.items(): 

72 row[verdict] = target 

73 matrix[name] = row 

74 return matrix 

75 

76 

77def _all_verdicts(matrix: dict[str, dict[str, str]]) -> list[str]: 

78 """Return ordered verdict columns: standard first, then custom alpha-sorted.""" 

79 seen: set[str] = set() 

80 for row in matrix.values(): 

81 seen.update(row.keys()) 

82 standard = [v for v in _STANDARD_VERDICTS if v in seen] 

83 extra = sorted(seen - set(_STANDARD_VERDICTS)) 

84 return standard + extra 

85 

86 

87class RouteTableRenderer: 

88 """Render a state×verdict matrix as markdown or CSV.""" 

89 

90 @staticmethod 

91 def to_markdown(matrix: dict[str, dict[str, str]]) -> str: 

92 verdicts = _all_verdicts(matrix) 

93 headers = ["state"] + verdicts 

94 rows = [ 

95 [state] + [row.get(v, EMPTY_CELL) for v in verdicts] for state, row in matrix.items() 

96 ] 

97 

98 # Column widths 

99 widths = [max(len(str(r[i])) for r in ([headers] + rows)) for i in range(len(headers))] 

100 

101 def fmt(cells: list[str]) -> str: 

102 return "| " + " | ".join(str(c).ljust(widths[i]) for i, c in enumerate(cells)) + " |" 

103 

104 lines = [ 

105 fmt(headers), 

106 "|" + "|".join("-" * (w + 2) for w in widths) + "|", 

107 ] 

108 lines.extend(fmt(row) for row in rows) 

109 return "\n".join(lines) + "\n" 

110 

111 @staticmethod 

112 def to_csv(matrix: dict[str, dict[str, str]]) -> str: 

113 verdicts = _all_verdicts(matrix) 

114 buf = io.StringIO() 

115 writer = csv.DictWriter(buf, fieldnames=["state"] + verdicts) 

116 writer.writeheader() 

117 for state_name, row in matrix.items(): 

118 writer.writerow({"state": state_name, **{v: row.get(v, "") for v in verdicts}}) 

119 return buf.getvalue() 

120 

121 

122class RouteTableParser: 

123 """Parse an edited markdown or CSV table back to a state×verdict matrix.""" 

124 

125 @staticmethod 

126 def parse_markdown(text: str, known_states: set[str]) -> ParsedTable: 

127 """Parse a markdown table into ParsedTable. 

128 

129 Unknown state names with all-empty verdict cells are classified as new_stubs. 

130 Unknown state names with non-empty cells raise ValueError. 

131 """ 

132 lines = [ln.strip() for ln in text.splitlines() if ln.strip().startswith("|")] 

133 if len(lines) < 2: 

134 raise ValueError("No valid markdown table found in input") 

135 

136 header_cells = [c.strip() for c in lines[0].strip("|").split("|")] 

137 verdicts = header_cells[1:] # skip "state" column 

138 

139 result: dict[str, dict[str, str]] = {} 

140 new_stubs: list[str] = [] 

141 for line in lines[2:]: # skip header + separator 

142 cells = [c.strip() for c in line.strip("|").split("|")] 

143 if not cells or not cells[0].strip(): 

144 continue 

145 state_name = cells[0].strip() 

146 row: dict[str, str] = {} 

147 for i, verdict in enumerate(verdicts): 

148 if i + 1 < len(cells): 

149 val = cells[i + 1].strip() 

150 if val and val not in (EMPTY_CELL, "-", ""): 

151 row[verdict] = val 

152 if state_name not in known_states: 

153 if not row: 

154 new_stubs.append(state_name) 

155 else: 

156 raise ValueError( 

157 f"Unknown state in edited table: '{state_name}' " 

158 f"(known: {sorted(known_states)})" 

159 ) 

160 else: 

161 result[state_name] = row 

162 deleted_states = [s for s in known_states if s not in result] 

163 return ParsedTable(matrix=result, new_stubs=new_stubs, deleted_states=deleted_states) 

164 

165 @staticmethod 

166 def parse_csv(text: str, known_states: set[str]) -> ParsedTable: 

167 """Parse a CSV table into ParsedTable. 

168 

169 Unknown state names with all-empty verdict cells are classified as new_stubs. 

170 Unknown state names with non-empty cells raise ValueError. 

171 """ 

172 reader = csv.DictReader(io.StringIO(text)) 

173 result: dict[str, dict[str, str]] = {} 

174 new_stubs: list[str] = [] 

175 for csv_row in reader: 

176 state_name = csv_row.pop("state", "").strip() 

177 if not state_name: 

178 continue 

179 row = {k.strip(): v for k, v in csv_row.items() if v and v not in (EMPTY_CELL, "-")} 

180 if state_name not in known_states: 

181 if not row: 

182 new_stubs.append(state_name) 

183 else: 

184 raise ValueError( 

185 f"Unknown state in edited table: '{state_name}' " 

186 f"(known: {sorted(known_states)})" 

187 ) 

188 else: 

189 result[state_name] = row 

190 deleted_states = [s for s in known_states if s not in result] 

191 return ParsedTable(matrix=result, new_stubs=new_stubs, deleted_states=deleted_states) 

192 

193 

194class RouteTableApplier: 

195 """Apply a matrix diff back to a loop YAML file, preserving non-route content.""" 

196 

197 @staticmethod 

198 def apply( 

199 path: Path, 

200 old_matrix: dict[str, dict[str, str]], 

201 new_matrix: dict[str, dict[str, str]], 

202 new_stubs: list[str] | None = None, 

203 allow_delete: bool = False, 

204 ) -> None: 

205 """Write changed routes from new_matrix back to the YAML loop file. 

206 

207 Args: 

208 path: Path to the loop YAML file. 

209 old_matrix: Original state×verdict matrix before editing. 

210 new_matrix: Edited state×verdict matrix (excludes deleted states and new stubs). 

211 new_stubs: State names to insert as terminal: true stubs. 

212 allow_delete: When True, removes states absent from new_matrix from the YAML. 

213 When False, emits a warning but leaves absent states unchanged. 

214 """ 

215 from io import StringIO 

216 

217 from ruamel.yaml import YAML 

218 from ruamel.yaml.comments import CommentedMap 

219 

220 from little_loops.file_utils import atomic_write 

221 

222 yaml = YAML(typ="rt") 

223 data = yaml.load(path) 

224 states_data = data.get("states", {}) 

225 changed = False 

226 

227 # Detect deleted states (known states absent from the edited table) 

228 deleted = set(old_matrix.keys()) - set(new_matrix.keys()) 

229 

230 if deleted: 

231 if allow_delete: 

232 # Warn about dangling routes to deleted states 

233 for remaining_state, remaining_routes in new_matrix.items(): 

234 for verdict, target in remaining_routes.items(): 

235 if target in deleted: 

236 print( 

237 f"⚠ Dangling route: '{remaining_state}' routes to " 

238 f"deleted state '{target}' via '{verdict}'" 

239 ) 

240 # Remove deleted states from YAML 

241 for state_name in deleted: 

242 states_data.pop(state_name, None) 

243 changed = True 

244 else: 

245 for state_name in sorted(deleted): 

246 print( 

247 f"⚠ State '{state_name}' removed from table but " 

248 f"--allow-delete not set; skipping deletion" 

249 ) 

250 

251 for state_name, new_row in new_matrix.items(): 

252 old_row = old_matrix.get(state_name, {}) 

253 if new_row == old_row: 

254 continue 

255 

256 state_data = states_data.get(state_name) 

257 if state_data is None: 

258 continue 

259 

260 uses_route_block = "route" in state_data 

261 

262 # Apply added/changed verdicts 

263 for verdict, new_target in new_row.items(): 

264 if old_row.get(verdict) == new_target: 

265 continue 

266 _write_route_field(state_data, verdict, new_target, uses_route_block) 

267 changed = True 

268 

269 # Remove deleted verdicts 

270 for verdict in old_row: 

271 if verdict not in new_row: 

272 _clear_route_field(state_data, verdict, uses_route_block) 

273 changed = True 

274 

275 # Insert new terminal stubs 

276 if new_stubs: 

277 for stub_name in new_stubs: 

278 states_data[stub_name] = CommentedMap({"terminal": True}) 

279 changed = True 

280 

281 if changed: 

282 buf = StringIO() 

283 yaml.dump(data, buf) 

284 atomic_write(path, buf.getvalue()) 

285 

286 

287def _write_route_field( 

288 state_data: Any, 

289 verdict: str, 

290 target: str, 

291 uses_route_block: bool, 

292) -> None: 

293 """Write a single verdict→target mapping into the ruamel state CommentedMap.""" 

294 if verdict == "next": 

295 state_data["next"] = target 

296 return 

297 if uses_route_block: 

298 if "route" not in state_data: 

299 state_data["route"] = {} 

300 if verdict == "default": 

301 state_data["route"]["_"] = target 

302 else: 

303 state_data["route"][verdict] = target 

304 else: 

305 field = _VERDICT_TO_FIELD.get(verdict) 

306 if field: 

307 state_data[field] = target 

308 else: 

309 # Extra route: stored as on_<verdict> in YAML 

310 state_data[f"on_{verdict}"] = target 

311 

312 

313def _clear_route_field( 

314 state_data: Any, 

315 verdict: str, 

316 uses_route_block: bool, 

317) -> None: 

318 """Remove a verdict from the ruamel state CommentedMap.""" 

319 if verdict == "next": 

320 state_data.pop("next", None) 

321 return 

322 if uses_route_block: 

323 route = state_data.get("route", {}) 

324 if verdict == "default": 

325 route.pop("_", None) 

326 else: 

327 route.pop(verdict, None) 

328 else: 

329 field = _VERDICT_TO_FIELD.get(verdict) 

330 if field: 

331 state_data.pop(field, None) 

332 else: 

333 state_data.pop(f"on_{verdict}", None) 

334 

335 

336# --------------------------------------------------------------------------- 

337# Compound decision-table types and helpers (ENH-2233) 

338# --------------------------------------------------------------------------- 

339 

340 

341@dataclass 

342class ParsedDecisionTable: 

343 """Result of parsing an edited compound policy-rule decision table.""" 

344 

345 rules: list[Rule] 

346 warnings: list[str] = field(default_factory=list) 

347 

348 

349def _all_dims(rules: list[Rule]) -> list[str]: 

350 """Return sorted union of dimension names from all non-catch-all rules.""" 

351 seen: set[str] = set() 

352 for rule in rules: 

353 for pred in rule.predicates: 

354 seen.add(pred.dim) 

355 return sorted(seen) 

356 

357 

358def _detect_shadows(rules: list[Rule]) -> list[str]: 

359 """Return warning strings for rules that are subsumed by an earlier rule.""" 

360 warnings: list[str] = [] 

361 for i, later in enumerate(rules): 

362 if later.is_catchall: 

363 continue 

364 later_preds = {(p.dim, p.op, p.value) for p in later.predicates} 

365 for j, earlier in enumerate(rules[:i]): 

366 if earlier.is_catchall: 

367 warnings.append( 

368 f"Rule {i + 1} (→ {later.target}) is shadowed by catch-all rule {j + 1}" 

369 ) 

370 break 

371 earlier_preds = {(p.dim, p.op, p.value) for p in earlier.predicates} 

372 if earlier_preds and earlier_preds.issubset(later_preds): 

373 warnings.append( 

374 f"Rule {i + 1} (→ {later.target}) may be shadowed by rule {j + 1} " 

375 f"(→ {earlier.target}): earlier rule has fewer/equal constraints" 

376 ) 

377 break 

378 return warnings 

379 

380 

381# Regex to parse a condition cell like ">=85", "<65", "==true" 

382_COND_PATTERN = re.compile(r"^(>=|<=|==|!=|<|>)(.+)$") 

383 

384 

385class PolicyRuleExtractor: 

386 """Extract policy rules from a loop's context.policy_rules field.""" 

387 

388 @staticmethod 

389 def extract(fsm: FSMLoop) -> list[Rule]: 

390 """Return parsed list of Rule from fsm.context['policy_rules'].""" 

391 from little_loops.fsm.policy_rules import parse_rules 

392 

393 text = str(fsm.context.get("policy_rules", "")) 

394 return parse_rules(text) 

395 

396 

397class CompoundGridRenderer: 

398 """Render a policy rule list as a compound condition×action grid.""" 

399 

400 @staticmethod 

401 def to_markdown(rules: list[Rule]) -> str: 

402 """Render rules as a markdown table with condition columns.""" 

403 dims = _all_dims(rules) 

404 headers = ["#"] + dims + ["→ action"] 

405 rows: list[list[str]] = [] 

406 for i, rule in enumerate(rules, 1): 

407 if rule.is_catchall: 

408 row = [str(i)] + ["*"] * len(dims) + [rule.target] 

409 else: 

410 pred_map = {p.dim: f"{p.op}{p.value}" for p in rule.predicates} 

411 row = [str(i)] + [pred_map.get(d, EMPTY_CELL) for d in dims] + [rule.target] 

412 rows.append(row) 

413 

414 widths = [max(len(str(r[i])) for r in ([headers] + rows)) for i in range(len(headers))] 

415 

416 def fmt(cells: list[str]) -> str: 

417 return "| " + " | ".join(str(c).ljust(widths[i]) for i, c in enumerate(cells)) + " |" 

418 

419 lines = [ 

420 fmt(headers), 

421 "|" + "|".join("-" * (w + 2) for w in widths) + "|", 

422 ] 

423 lines.extend(fmt(row) for row in rows) 

424 return "\n".join(lines) + "\n" 

425 

426 @staticmethod 

427 def to_csv(rules: list[Rule]) -> str: 

428 """Render rules as CSV with condition columns.""" 

429 dims = _all_dims(rules) 

430 buf = io.StringIO() 

431 writer = csv.DictWriter(buf, fieldnames=["#"] + dims + ["→ action"]) 

432 writer.writeheader() 

433 for i, rule in enumerate(rules, 1): 

434 if rule.is_catchall: 

435 row: dict[str, str] = {"#": str(i), "→ action": rule.target} 

436 for d in dims: 

437 row[d] = "*" 

438 else: 

439 pred_map = {p.dim: f"{p.op}{p.value}" for p in rule.predicates} 

440 row = {"#": str(i), "→ action": rule.target} 

441 for d in dims: 

442 row[d] = pred_map.get(d, "") 

443 writer.writerow(row) 

444 return buf.getvalue() 

445 

446 

447def _parse_cond_cell(dim: str, val: str) -> Any: 

448 """Parse a condition cell string (e.g. '>=85') into a Predicate.""" 

449 from little_loops.fsm.policy_rules import Predicate 

450 

451 m = _COND_PATTERN.match(val) 

452 if not m: 

453 raise ValueError( 

454 f"Cannot parse condition cell {val!r} for dimension {dim!r}; " 

455 f"expected operator prefix (>=, <=, ==, !=, <, >)" 

456 ) 

457 return Predicate(dim=dim, op=m.group(1), value=m.group(2).strip()) 

458 

459 

460def _parse_rule_cells( 

461 dims: list[str], dim_cells: dict[str, str], action: str, known_states: set[str] 

462) -> tuple[Any, list[str]]: 

463 """Parse dimension cells into a Rule and return (rule, unknown_action_warnings).""" 

464 from little_loops.fsm.policy_rules import Predicate 

465 from little_loops.fsm.policy_rules import Rule as PolicyRule 

466 

467 warnings: list[str] = [] 

468 if known_states and action not in known_states: 

469 warnings.append(f"Action state '{action}' is not a known state in this loop") 

470 

471 is_catchall = all(v in ("*", "", EMPTY_CELL, "-") for v in dim_cells.values()) and any( 

472 v == "*" for v in dim_cells.values() 

473 ) 

474 

475 if is_catchall: 

476 return PolicyRule(predicates=[], target=action, is_catchall=True), warnings 

477 

478 preds: list[Predicate] = [] 

479 for dim in dims: 

480 val = dim_cells.get(dim, "").strip() 

481 if not val or val in (EMPTY_CELL, "-", ""): 

482 continue 

483 if val == "*": 

484 continue 

485 preds.append(_parse_cond_cell(dim, val)) 

486 return PolicyRule(predicates=preds, target=action, is_catchall=False), warnings 

487 

488 

489class CompoundGridParser: 

490 """Parse an edited compound grid (markdown or CSV) back to a list of Rule.""" 

491 

492 @staticmethod 

493 def parse_markdown(text: str, known_states: set[str]) -> ParsedDecisionTable: 

494 """Parse edited markdown table back to a ParsedDecisionTable. 

495 

496 Validates action states, warns on missing catch-all and shadowed rows. 

497 """ 

498 lines = [ln.strip() for ln in text.splitlines() if ln.strip().startswith("|")] 

499 if len(lines) < 2: 

500 raise ValueError("No valid markdown table found in input") 

501 

502 header_cells = [c.strip() for c in lines[0].strip("|").split("|")] 

503 if not header_cells or header_cells[-1] not in ("→ action",): 

504 raise ValueError( 

505 f"Decision table must end with a '→ action' column; got {header_cells[-1]!r}" 

506 ) 

507 dims = header_cells[1:-1] 

508 

509 rules = [] 

510 all_warnings: list[str] = [] 

511 for line in lines[2:]: 

512 cells = [c.strip() for c in line.strip("|").split("|")] 

513 if not cells or not cells[0].strip(): 

514 continue 

515 if len(cells) < len(dims) + 2: 

516 continue 

517 action = cells[-1].strip() 

518 if not action or action in (EMPTY_CELL, "-"): 

519 continue 

520 dim_cells = {dims[k]: cells[k + 1].strip() for k in range(len(dims))} 

521 rule, warns = _parse_rule_cells(dims, dim_cells, action, known_states) 

522 rules.append(rule) 

523 all_warnings.extend(warns) 

524 

525 if not rules or not rules[-1].is_catchall: 

526 all_warnings.append( 

527 "Decision table has no catch-all rule ('*'). " 

528 "Unmatched inputs will produce no route." 

529 ) 

530 all_warnings.extend(_detect_shadows(rules)) 

531 return ParsedDecisionTable(rules=rules, warnings=all_warnings) 

532 

533 @staticmethod 

534 def parse_csv(text: str, known_states: set[str]) -> ParsedDecisionTable: 

535 """Parse edited CSV table back to a ParsedDecisionTable.""" 

536 reader = csv.DictReader(io.StringIO(text)) 

537 fieldnames = list(reader.fieldnames or []) 

538 if "→ action" not in fieldnames: 

539 raise ValueError("CSV must have a '→ action' column") 

540 dims = [f for f in fieldnames if f not in ("#", "→ action")] 

541 

542 rules = [] 

543 all_warnings: list[str] = [] 

544 for csv_row in reader: 

545 action = csv_row.get("→ action", "").strip() 

546 if not action: 

547 continue 

548 dim_cells = {d: csv_row.get(d, "").strip() for d in dims} 

549 rule, warns = _parse_rule_cells(dims, dim_cells, action, known_states) 

550 rules.append(rule) 

551 all_warnings.extend(warns) 

552 

553 if not rules or not rules[-1].is_catchall: 

554 all_warnings.append( 

555 "Decision table has no catch-all rule ('*'). " 

556 "Unmatched inputs will produce no route." 

557 ) 

558 all_warnings.extend(_detect_shadows(rules)) 

559 return ParsedDecisionTable(rules=rules, warnings=all_warnings) 

560 

561 

562class PolicyRuleApplier: 

563 """Write edited policy rules back to a loop YAML file.""" 

564 

565 @staticmethod 

566 def apply(path: Path, rules: list[Rule]) -> None: 

567 """Serialize rules and write back to context.policy_rules in the loop YAML.""" 

568 from io import StringIO 

569 

570 from ruamel.yaml import YAML 

571 from ruamel.yaml.scalarstring import LiteralScalarString 

572 

573 from little_loops.file_utils import atomic_write 

574 from little_loops.fsm.policy_rules import serialize_rules 

575 

576 yaml = YAML(typ="rt") 

577 data = yaml.load(path) 

578 serialized = serialize_rules(rules) 

579 data["context"]["policy_rules"] = LiteralScalarString(serialized + "\n") 

580 buf = StringIO() 

581 yaml.dump(data, buf) 

582 atomic_write(path, buf.getvalue()) 

583 

584 

585def detect_routing_gaps(fsm: FSMLoop) -> list[str]: 

586 """Detect unreachable states, dead-end states, and missing verdict arms.""" 

587 from little_loops.fsm.validation import _find_reachable_states 

588 

589 warnings: list[str] = [] 

590 reachable = _find_reachable_states(fsm) 

591 

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

593 if state_name not in reachable: 

594 warnings.append(f"Unreachable state: '{state_name}' has no route leading to it") 

595 

596 if state.terminal: 

597 continue 

598 

599 has_any_route = ( 

600 state.on_yes 

601 or state.on_no 

602 or state.on_error 

603 or state.on_partial 

604 or state.on_blocked 

605 or state.next 

606 or state.route is not None 

607 or state.extra_routes 

608 ) 

609 if not has_any_route: 

610 warnings.append( 

611 f"Dead-end state: '{state_name}' has no outbound routes and is not terminal" 

612 ) 

613 continue 

614 

615 # Missing arm: on_yes present but no on_no and no catch-all 

616 has_default = (state.route and state.route.default is not None) or state.next is not None 

617 if state.on_yes and not state.on_no and not has_default: 

618 warnings.append( 

619 f"Missing verdict arm: '{state_name}' has on_yes but no on_no or default" 

620 ) 

621 

622 return warnings 

623 

624 

625def open_in_editor(table_text: str, fmt: str = "markdown") -> str | None: 

626 """Write table to a temp file, open $EDITOR, return edited text (or None on cancel).""" 

627 suffix = ".md" if fmt == "markdown" else ".csv" 

628 with tempfile.NamedTemporaryFile( 

629 mode="w", suffix=suffix, delete=False, encoding="utf-8" 

630 ) as tmp: 

631 tmp.write(table_text) 

632 tmp_path = tmp.name 

633 

634 try: 

635 editor = os.environ.get("EDITOR", "vi") 

636 rc = subprocess.call([editor, tmp_path]) 

637 if rc != 0: 

638 return None 

639 return Path(tmp_path).read_text(encoding="utf-8") 

640 finally: 

641 try: 

642 os.unlink(tmp_path) 

643 except OSError: 

644 pass