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

2

3 from __future__ import annotations

4

5 import csv

6 import io

7 import os

8 import re

9 import subprocess

10 import tempfile

11 from dataclasses import dataclass, field

12 from pathlib import Path

13 from typing import TYPE_CHECKING, Any

14

15 from little_loops.fsm.policy_rules import _ALL_OPS

16

17 if TYPE_CHECKING:

18 from little_loops.fsm.policy_rules import Rule

19 from little_loops.fsm.schema import FSMLoop

20

21# Ordered standard verdict labels for column display

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

23

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

25_VERDICT_TO_FIELD: dict[str, str] = {

26 "yes": "on_yes",

27 "no": "on_no",

28 "error": "on_error",

29 "partial": "on_partial",

30 "blocked": "on_blocked",

31 "next": "next",

32}

33

34 EMPTY_CELL = "—"

35

36

37@dataclass

38 class ParsedTable:

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

40

41 matrix: dict[str, dict[str, str]]

42 new_stubs: list[str]

43 deleted_states: list[str]

44

45

46 class RouteTableExtractor:

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

48

49 @staticmethod

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

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

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

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

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

55 # Shorthand fields

56 for verdict, target in [

57 ("yes", state.on_yes),

58 ("no", state.on_no),

59 ("error", state.on_error),

60 ("partial", state.on_partial),

61 ("blocked", state.on_blocked),

62 ("next", state.next),

63 ]:

64 if target:

65 row[verdict] = target

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

67 if state.route:

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

69 row[verdict] = target

70 if state.route.default:

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

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

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

74 row[verdict] = target

75 matrix[name] = row

76 return matrix

77

78

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

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

81 seen: set[str] = set()

82 for row in matrix.values():

83 seen.update(row.keys())

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

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

86 return standard + extra

87

88

89 class RouteTableRenderer:

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

91

92 @staticmethod

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

94 verdicts = _all_verdicts(matrix)

95 headers = ["state"] + verdicts

96 rows = [

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

98 ]

99

100 # Column widths

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

102

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

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

105

106 lines = [

107 fmt(headers),

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

109 ]

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

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

112

113 @staticmethod

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

115 verdicts = _all_verdicts(matrix)

116 buf = io.StringIO()

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

118 writer.writeheader()

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

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

121 return buf.getvalue()

122

123

124 class RouteTableParser:

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

126

127 @staticmethod

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

129 """Parse a markdown table into ParsedTable.

130

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

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

133 """

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

135 if len(lines) < 2:

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

137

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

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

140

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

142 new_stubs: list[str] = []

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

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

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

146 continue

147 state_name = cells[0].strip()

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

149 for i, verdict in enumerate(verdicts):

150 if i + 1 < len(cells):

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

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

153 row[verdict] = val

154 if state_name not in known_states:

155 if not row:

156 new_stubs.append(state_name)

157 else:

158 raise ValueError(

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

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

161 )

162 else:

163 result[state_name] = row

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

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

166

167 @staticmethod

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

169 """Parse a CSV table into ParsedTable.

170

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

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

173 """

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

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

176 new_stubs: list[str] = []

177 for csv_row in reader:

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

179 if not state_name:

180 continue

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

182 if state_name not in known_states:

183 if not row:

184 new_stubs.append(state_name)

185 else:

186 raise ValueError(

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

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

189 )

190 else:

191 result[state_name] = row

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

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

194

195

196 class RouteTableApplier:

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

198

199 @staticmethod

200 def apply(

201 path: Path,

202 old_matrix: dict[str, dict[str, str]],

203 new_matrix: dict[str, dict[str, str]],

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

205 allow_delete: bool = False,

206 ) -> None:

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

208

209 Args:

210 path: Path to the loop YAML file.

211 old_matrix: Original state×verdict matrix before editing.

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

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

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

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

216 """

217 from io import StringIO

218

219 from ruamel.yaml import YAML

220 from ruamel.yaml.comments import CommentedMap

221

222 from little_loops.file_utils import atomic_write

223

224 yaml = YAML(typ="rt")

225 data = yaml.load(path)

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

227 changed = False

228

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

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

231

232 if deleted:

233 if allow_delete:

234 # Warn about dangling routes to deleted states

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

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

237 if target in deleted:

238 print(

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

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

241 )

242 # Remove deleted states from YAML

243 for state_name in deleted:

244 states_data.pop(state_name, None)

245 changed = True

246 else:

247 for state_name in sorted(deleted):

248 print(

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

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

251 )

252

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

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

255 if new_row == old_row:

256 continue

257

258 state_data = states_data.get(state_name)

259 if state_data is None:

260 continue

261

262 uses_route_block = "route" in state_data

263

264 # Apply added/changed verdicts

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

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

267 continue

268 _write_route_field(state_data, verdict, new_target, uses_route_block)

269 changed = True

270

271 # Remove deleted verdicts

272 for verdict in old_row:

273 if verdict not in new_row:

274 _clear_route_field(state_data, verdict, uses_route_block)

275 changed = True

276

277 # Insert new terminal stubs

278 if new_stubs:

279 for stub_name in new_stubs:

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

281 changed = True

282

283 if changed:

284 buf = StringIO()

285 yaml.dump(data, buf)

286 atomic_write(path, buf.getvalue())

287

288

289 def _write_route_field(

290 state_data: Any,

291 verdict: str,

292 target: str,

293 uses_route_block: bool,

294) -> None:

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

296 if verdict == "next":

297 state_data["next"] = target

298 return

299 if uses_route_block:

300 if "route" not in state_data:

301 state_data["route"] = {}

302 if verdict == "default":

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

304 else:

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

306 else:

307 field = _VERDICT_TO_FIELD.get(verdict)

308 if field:

309 state_data[field] = target

310 else:

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

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

313

314

315 def _clear_route_field(

316 state_data: Any,

317 verdict: str,

318 uses_route_block: bool,

319) -> None:

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

321 if verdict == "next":

322 state_data.pop("next", None)

323 return

324 if uses_route_block:

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

326 if verdict == "default":

327 route.pop("_", None)

328 else:

329 route.pop(verdict, None)

330 else:

331 field = _VERDICT_TO_FIELD.get(verdict)

332 if field:

333 state_data.pop(field, None)

334 else:

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

336

337

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

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

340# ---------------------------------------------------------------------------

341

342

343@dataclass

344 class ParsedDecisionTable:

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

346

347 rules: list[Rule]

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

349

350

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

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

353 seen: set[str] = set()

354 for rule in rules:

355 for pred in rule.predicates:

356 seen.add(pred.dim)

357 return sorted(seen)

358

359

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

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

362 warnings: list[str] = []

363 for i, later in enumerate(rules):

364 if later.is_catchall:

365 continue

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

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

368 if earlier.is_catchall:

369 warnings.append(

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

371 )

372 break

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

374 if earlier_preds and earlier_preds.issubset(later_preds):

375 warnings.append(

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

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

378 )

379 break

380 return warnings

381

382

383# Multi-char operators must precede their single-char prefixes for longest-match

384# (e.g. ">=" before ">"). Sort by length descending; alphabetical order is NOT safe.

385_OP_ALT = "|".join(sorted(_ALL_OPS, key=len, reverse=True))

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

387_COND_PATTERN = re.compile(rf"^({_OP_ALT})(.+)$")

388

389

390 class PolicyRuleExtractor:

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

392

393 @staticmethod

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

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

396 from little_loops.fsm.policy_rules import parse_rules

397

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

399 return parse_rules(text)

400

401

402 class CompoundGridRenderer:

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

404

405 @staticmethod

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

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

408 dims = _all_dims(rules)

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

410 rows: list[list[str]] = []

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

412 if rule.is_catchall:

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

414 else:

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

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

417 rows.append(row)

418

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

420

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

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

423

424 lines = [

425 fmt(headers),

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

427 ]

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

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

430

431 @staticmethod

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

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

434 dims = _all_dims(rules)

435 buf = io.StringIO()

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

437 writer.writeheader()

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

439 if rule.is_catchall:

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

441 for d in dims:

442 row[d] = "*"

443 else:

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

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

446 for d in dims:

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

448 writer.writerow(row)

449 return buf.getvalue()

450

451

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

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

454 from little_loops.fsm.policy_rules import Predicate

455

456 m = _COND_PATTERN.match(val)

457 if not m:

458 raise ValueError(

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

460 f"expected operator prefix ({_OP_ALT.replace('|', ', ')})"

461 )

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

463

464

465 def _parse_rule_cells(

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

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

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

469 from little_loops.fsm.policy_rules import Predicate

470 from little_loops.fsm.policy_rules import Rule as PolicyRule

471

472 warnings: list[str] = []

473 if known_states and action not in known_states:

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

475

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

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

478 )

479

480 if is_catchall:

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

482

483 preds: list[Predicate] = []

484 for dim in dims:

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

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

487 continue

488 if val == "*":

489 continue

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

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

492

493

494 class CompoundGridParser:

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

496

497 @staticmethod

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

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

500

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

502 """

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

504 if len(lines) < 2:

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

506

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

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

509 raise ValueError(

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

511 )

512 dims = header_cells[1:-1]

513

514 rules = []

515 all_warnings: list[str] = []

516 for line in lines[2:]:

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

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

519 continue

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

521 continue

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

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

524 continue

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

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

527 rules.append(rule)

528 all_warnings.extend(warns)

529

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

531 all_warnings.append(

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

533 "Unmatched inputs will produce no route."

534 )

535 all_warnings.extend(_detect_shadows(rules))

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

537

538 @staticmethod

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

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

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

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

543 if "→ action" not in fieldnames:

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

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

546

547 rules = []

548 all_warnings: list[str] = []

549 for csv_row in reader:

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

551 if not action:

552 continue

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

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

555 rules.append(rule)

556 all_warnings.extend(warns)

557

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

559 all_warnings.append(

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

561 "Unmatched inputs will produce no route."

562 )

563 all_warnings.extend(_detect_shadows(rules))

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

565

566

567 class PolicyRuleApplier:

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

569

570 @staticmethod

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

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

573 from io import StringIO

574

575 from ruamel.yaml import YAML

576 from ruamel.yaml.scalarstring import LiteralScalarString

577

578 from little_loops.file_utils import atomic_write

579 from little_loops.fsm.policy_rules import serialize_rules

580

581 yaml = YAML(typ="rt")

582 data = yaml.load(path)

583 serialized = serialize_rules(rules)

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

585 buf = StringIO()

586 yaml.dump(data, buf)

587 atomic_write(path, buf.getvalue())

588

589

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

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

592 from little_loops.fsm.validation import _find_reachable_states

593

594 warnings: list[str] = []

595 reachable = _find_reachable_states(fsm)

596

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

598 if state_name not in reachable:

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

600

601 if state.terminal:

602 continue

603

604 has_any_route = (

605 state.on_yes

606 or state.on_no

607 or state.on_error

608 or state.on_partial

609 or state.on_blocked

610 or state.next

611 or state.route is not None

612 or state.extra_routes

613 )

614 if not has_any_route:

615 warnings.append(

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

617 )

618 continue

619

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

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

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

623 warnings.append(

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

625 )

626

627 return warnings

628

629

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

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

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

633 with tempfile.NamedTemporaryFile(

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

635 ) as tmp:

636 tmp.write(table_text)

637 tmp_path = tmp.name

638

639 try:

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

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

642 if rc != 0:

643 return None

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

645 finally:

646 try:

647 os.unlink(tmp_path)

648 except OSError:

649 pass