Coverage for little_loops/fsm/compilers.py: 100%

1"""Paradigm compilers for FSM loop generation.

2

3 Each paradigm (goal, convergence, invariants, imperative) compiles to the

4 universal FSM schema via deterministic template expansion. This provides

5 a simple authoring experience while maintaining a single execution engine.

6

7 Compilers transform high-level paradigm YAML specifications into FSMLoop

8 instances that can be validated and executed. Each compiler is a pure

9 function (~50-100 lines) that performs template expansion with variable

10 substitution.

11

12 Example usage:

13 >>> spec = {

14 ... "paradigm": "goal",

15 ... "goal": "No type errors in src/",

16 ... "tools": ["/ll:check-code types", "/ll:manage-issue bug fix"],

17 ... }

18 >>> fsm = compile_paradigm(spec)

19 >>> fsm.initial

20 'evaluate'

21"""

22

23 from __future__ import annotations

24

25 import re

26 from typing import Any

27

28 from little_loops.fsm.schema import (

29 EvaluateConfig,

30 FSMLoop,

31 RouteConfig,

32 StateConfig,

33)

34

35

36 def _slugify(text: str, max_length: int = 50) -> str:

37 """Convert text to slug format for FSM names.

38

39 Args:

40 text: Text to convert to slug

41 max_length: Maximum length of the resulting slug

42

43 Returns:

44 Lowercase slug with hyphens, truncated to max_length

45 """

46 text = re.sub(r"[^\w\s-]", "", text)

47 text = re.sub(r"[-\s]+", "-", text)

48 return text.strip("-").lower()[:max_length]

49

50

51 def _build_evaluate_config(evaluator_spec: dict[str, Any] | None) -> EvaluateConfig | None:

52 """Build EvaluateConfig from evaluator specification dict.

53

54 Args:

55 evaluator_spec: Optional evaluator configuration dict with keys:

56 - type: Evaluator type (exit_code, output_contains, output_numeric, llm_structured)

57 - pattern: Pattern string for output_contains

58 - operator: Comparison operator for output_numeric (eq, lt, gt, le, ge, ne)

59 - target: Target value for output_numeric

60

61 Returns:

62 EvaluateConfig instance if spec provided, None otherwise

63 """

64 if evaluator_spec is None:

65 return None

66

67 eval_type = evaluator_spec.get("type", "exit_code")

68

69 # For exit_code, we can return None to use the default behavior

70 if eval_type == "exit_code":

71 return None

72

73 return EvaluateConfig(

74 type=eval_type,

75 pattern=evaluator_spec.get("pattern"),

76 operator=evaluator_spec.get("operator"),

77 target=evaluator_spec.get("target"),

78 )

79

80

81 def compile_paradigm(spec: dict[str, Any]) -> FSMLoop:

82 """Route to appropriate compiler based on paradigm field.

83

84 This is the main entry point for paradigm compilation. It examines

85 the 'paradigm' field in the spec and routes to the appropriate

86 compiler function.

87

88 Args:

89 spec: Paradigm specification dictionary. Must include a 'paradigm'

90 field (defaults to 'fsm' if not present).

91

92 Returns:

93 Compiled FSMLoop instance

94

95 Raises:

96 ValueError: If the paradigm is unknown

97

98 Example:

99 >>> spec = {"paradigm": "goal", "goal": "Clean", "tools": ["cmd"]}

100 >>> fsm = compile_paradigm(spec)

101 >>> fsm.paradigm

102 'goal'

103 """

104 paradigm = spec.get("paradigm", "fsm")

105

106 compilers = {

107 "goal": compile_goal,

108 "convergence": compile_convergence,

109 "invariants": compile_invariants,

110 "imperative": compile_imperative,

111 "fsm": _passthrough_fsm,

112 }

113

114 if paradigm not in compilers:

115 raise ValueError(

116 f"Unknown paradigm: '{paradigm}'. Must be one of: {', '.join(sorted(compilers.keys()))}"

117 )

118

119 return compilers[paradigm](spec)

120

121

122 def _passthrough_fsm(spec: dict[str, Any]) -> FSMLoop:

123 """Pass through FSM spec directly (no compilation needed).

124

125 Args:

126 spec: FSM specification dictionary matching FSMLoop schema

127

128 Returns:

129 FSMLoop instance created from the spec

130 """

131 return FSMLoop.from_dict(spec)

132

133

134 def compile_goal(spec: dict[str, Any]) -> FSMLoop:

135 """Compile goal paradigm to FSM.

136

137 Goal paradigm: evaluate → (success → done, failure → fix), fix → evaluate

138

139 The goal paradigm is the simplest: it repeatedly checks a condition

140 and applies a fix until the goal is achieved.

141

142 Input spec:

143 paradigm: goal

144 goal: "No type errors in src/"

145 tools:

146 - /ll:check-code types # Check tool (first)

147 - /ll:manage-issue bug fix # Fix tool (second, optional)

148 max_iterations: 50 # Optional, defaults to 50 (examples show 20 for brevity)

149 name: "my-goal" # Optional, auto-generated from goal

150 evaluator: # Optional evaluator config

151 type: output_contains

152 pattern: "Success"

153

154 Args:

155 spec: Goal paradigm specification dict

156

157 Returns:

158 Compiled FSMLoop instance with evaluate/fix/done states

159

160 Raises:

161 ValueError: If required fields are missing

162 """

163 # Validate required fields

164 required = ["goal", "tools"]

165 missing = [f for f in required if f not in spec]

166 if missing:

167 raise ValueError(f"Goal paradigm requires: {', '.join(missing)}")

168

169 if "tools" in spec and not spec["tools"]:

170 raise ValueError("Goal paradigm 'tools' requires at least one tool")

171

172 goal = spec["goal"]

173 tools = spec["tools"]

174 check_tool = tools[0]

175 fix_tool = tools[1] if len(tools) > 1 else tools[0]

176

177 name = spec.get("name", f"goal-{_slugify(goal)}")

178

179 # Extract evaluator config if provided

180 evaluate_config = _build_evaluate_config(spec.get("evaluator"))

181

182 states = {

183 "evaluate": StateConfig(

184 action=check_tool,

185 evaluate=evaluate_config,

186 on_success="done",

187 on_failure="fix",

188 on_error="fix",

189 ),

190 "fix": StateConfig(

191 action=fix_tool,

192 next="evaluate",

193 ),

194 "done": StateConfig(terminal=True),

195 }

196

197 return FSMLoop(

198 name=name,

199 paradigm="goal",

200 initial="evaluate",

201 states=states,

202 max_iterations=spec.get("max_iterations", 50),

203 backoff=spec.get("backoff"),

204 timeout=spec.get("timeout"),

205 on_handoff=spec.get("on_handoff", "pause"),

206 )

207

208

209 def compile_convergence(spec: dict[str, Any]) -> FSMLoop:

210 """Compile convergence paradigm to FSM.

211

212 Convergence paradigm: measure → (target → done, progress → apply, stall → done),

213 apply → measure

214

215 The convergence paradigm drives a metric toward a target value. It measures

216 the current value, compares to the previous measurement, and routes based

217 on whether progress is being made.

218

219 Input spec:

220 paradigm: convergence

221 name: "reduce-lint-errors"

222 check: "ruff check src/ --output-format=json | jq '.count'"

223 toward: 0

224 using: "/ll:check-code fix"

225 tolerance: 0 # Optional, defaults to 0

226

227 Args:

228 spec: Convergence paradigm specification dict

229

230 Returns:

231 Compiled FSMLoop instance with measure/apply/done states

232

233 Raises:

234 ValueError: If required fields are missing

235 """

236 # Validate required fields

237 required = ["name", "check", "toward", "using"]

238 missing = [f for f in required if f not in spec]

239 if missing:

240 raise ValueError(f"Convergence paradigm requires: {', '.join(missing)}")

241

242 name = spec["name"]

243 metric_cmd = spec["check"]

244 target = spec["toward"]

245 fix_action = spec["using"]

246 tolerance = spec.get("tolerance", 0)

247

248 # Build context for variable interpolation

249 context = {

250 "metric_cmd": metric_cmd,

251 "target": target,

252 "tolerance": tolerance,

253 }

254

255 states = {

256 "measure": StateConfig(

257 action="${context.metric_cmd}",

258 capture="current_value",

259 evaluate=EvaluateConfig(

260 type="convergence",

261 target="${context.target}",

262 tolerance="${context.tolerance}",

263 previous="${prev.output}",

264 ),

265 route=RouteConfig(

266 routes={

267 "target": "done",

268 "progress": "apply",

269 "stall": "done",

270 }

271 ),

272 ),

273 "apply": StateConfig(

274 action=fix_action,

275 next="measure",

276 ),

277 "done": StateConfig(terminal=True),

278 }

279

280 return FSMLoop(

281 name=name,

282 paradigm="convergence",

283 initial="measure",

284 states=states,

285 context=context,

286 max_iterations=spec.get("max_iterations", 50),

287 backoff=spec.get("backoff"),

288 timeout=spec.get("timeout"),

289 )

290

291

292 def compile_invariants(spec: dict[str, Any]) -> FSMLoop:

293 """Compile invariants paradigm to FSM.

294

295 Invariants paradigm: check_1 → (success → check_2, failure → fix_1),

296 fix_1 → check_1, ...

297

298 The invariants paradigm chains multiple constraints. Each constraint

299 is checked in sequence; if any fails, its fix is applied before

300 re-checking. When all pass, the loop can optionally restart (maintain mode).

301

302 Input spec:

303 paradigm: invariants

304 name: "code-quality-guardian"

305 constraints:

306 - name: "tests-pass"

307 check: "pytest"

308 fix: "/ll:manage-issue bug fix"

309 evaluator: # Optional per-constraint

310 type: output_contains

311 pattern: "passed"

312 - name: "lint-clean"

313 check: "ruff check src/"

314 fix: "/ll:check-code fix"

315 maintain: true # Optional, restarts after all valid

316

317 Args:

318 spec: Invariants paradigm specification dict

319

320 Returns:

321 Compiled FSMLoop instance with check/fix pairs and all_valid terminal

322

323 Raises:

324 ValueError: If required fields are missing or constraint is invalid

325 """

326 # Validate required fields

327 required = ["name", "constraints"]

328 missing = [f for f in required if f not in spec]

329 if missing:

330 raise ValueError(f"Invariants paradigm requires: {', '.join(missing)}")

331

332 if "constraints" in spec and not spec["constraints"]:

333 raise ValueError("Invariants paradigm 'constraints' requires at least one constraint")

334

335 name = spec["name"]

336 constraints = spec["constraints"]

337 maintain = spec.get("maintain", False)

338

339 # Validate each constraint

340 for i, constraint in enumerate(constraints):

341 if "name" not in constraint:

342 raise ValueError(f"Constraint {i} requires 'name' field")

343 if "check" not in constraint:

344 raise ValueError(f"Constraint '{constraint.get('name', i)}' requires 'check' field")

345 if "fix" not in constraint:

346 raise ValueError(f"Constraint '{constraint.get('name', i)}' requires 'fix' field")

347

348 states: dict[str, StateConfig] = {}

349

350 for i, constraint in enumerate(constraints):

351 check_state = f"check_{constraint['name']}"

352 fix_state = f"fix_{constraint['name']}"

353

354 # Next check state or terminal

355 next_check = (

356 f"check_{constraints[i + 1]['name']}" if i + 1 < len(constraints) else "all_valid"

357 )

358

359 # Extract evaluator config if provided for this constraint

360 evaluate_config = _build_evaluate_config(constraint.get("evaluator"))

361

362 states[check_state] = StateConfig(

363 action=constraint["check"],

364 evaluate=evaluate_config,

365 on_success=next_check,

366 on_failure=fix_state,

367 )

368 states[fix_state] = StateConfig(

369 action=constraint["fix"],

370 next=check_state,

371 )

372

373 # Terminal state with optional maintain loop-back

374 first_check = f"check_{constraints[0]['name']}"

375 states["all_valid"] = StateConfig(

376 terminal=True,

377 on_maintain=first_check if maintain else None,

378 )

379

380 return FSMLoop(

381 name=name,

382 paradigm="invariants",

383 initial=first_check,

384 states=states,

385 maintain=maintain,

386 max_iterations=spec.get("max_iterations", 50),

387 backoff=spec.get("backoff"),

388 timeout=spec.get("timeout"),

389 )

390

391

392 def compile_imperative(spec: dict[str, Any]) -> FSMLoop:

393 """Compile imperative paradigm to FSM.

394

395 Imperative paradigm: step_0 → step_1 → ... → check_done →

396 (success → done, failure → step_0)

397

398 The imperative paradigm runs a sequence of steps, then checks an

399 exit condition. If the condition fails, the sequence restarts from

400 the beginning.

401

402 Input spec:

403 paradigm: imperative

404 name: "fix-all-types"

405 steps:

406 - /ll:check-code types

407 - /ll:manage-issue bug fix

408 until:

409 check: "mypy src/"

410 passes: true

411 evaluator: # Optional evaluator for exit condition

412 type: output_contains

413 pattern: "Success"

414 max_iterations: 50 # Optional, defaults to 50 (examples show 20 for brevity)

415 backoff: 2 # Seconds between iterations

416

417 Args:

418 spec: Imperative paradigm specification dict

419

420 Returns:

421 Compiled FSMLoop instance with step sequence and check_done

422

423 Raises:

424 ValueError: If required fields are missing

425 """

426 # Validate required fields

427 required = ["name", "steps", "until"]

428 missing = [f for f in required if f not in spec]

429 if missing:

430 raise ValueError(f"Imperative paradigm requires: {', '.join(missing)}")

431

432 if "steps" in spec and not spec["steps"]:

433 raise ValueError("Imperative paradigm 'steps' requires at least one step")

434

435 if "check" not in spec["until"]:

436 raise ValueError("Imperative paradigm 'until' requires 'check' field")

437

438 name = spec["name"]

439 steps = spec["steps"]

440 until_check = spec["until"]["check"]

441

442 # Extract evaluator config for exit condition if provided

443 evaluate_config = _build_evaluate_config(spec["until"].get("evaluator"))

444

445 states: dict[str, StateConfig] = {}

446

447 # Create step states

448 for i, step in enumerate(steps):

449 state_name = f"step_{i}"

450 next_state = f"step_{i + 1}" if i + 1 < len(steps) else "check_done"

451

452 states[state_name] = StateConfig(

453 action=step,

454 next=next_state,

455 )

456

457 # Create check_done state with optional evaluator

458 states["check_done"] = StateConfig(

459 action=until_check,

460 evaluate=evaluate_config,

461 on_success="done",

462 on_failure="step_0",

463 )

464

465 # Terminal state

466 states["done"] = StateConfig(terminal=True)

467

468 return FSMLoop(

469 name=name,

470 paradigm="imperative",

471 initial="step_0",

472 states=states,

473 max_iterations=spec.get("max_iterations", 50),

474 backoff=spec.get("backoff"),

475 timeout=spec.get("timeout"),

476 )