|
1 | | -"""L3 — Tree search (ToT baseline + LATS-like escalation hooks).""" |
2 | | - |
3 | | -from __future__ import annotations |
4 | | - |
5 | | -from dataclasses import dataclass |
6 | | -from typing import Any, Dict, List, Tuple |
7 | | - |
8 | | -from epistemic_forge.models import ProjectSpec, SearchNode |
9 | | - |
10 | | - |
11 | | -@dataclass |
12 | | -class SearchResult: |
13 | | - best_thought: str |
14 | | - nodes: List[SearchNode] |
15 | | - mode_used: str |
16 | | - score: float |
17 | | - |
18 | | - |
19 | | -def _value_label(text: str, spec: ProjectSpec) -> float: |
20 | | - """ToT-like sure/likely/impossible mapped to floats.""" |
21 | | - t = text.lower() |
22 | | - score = 0.35 |
23 | | - if any(w in t for w in ("because", "evidence", "therefore", "tradeoff", "metric")): |
24 | | - score += 0.2 |
25 | | - if any(w in t for w in ("unknown", "risk", "limit", "bias", "leak")): |
26 | | - score += 0.15 # epistemic humility is valuable |
27 | | - overlap = sum(1 for k in spec.keywords if k.lower() in t) |
28 | | - score += min(0.2, overlap * 0.05) |
29 | | - if "todo" in t or "vague" in t: |
30 | | - score -= 0.2 |
31 | | - return max(0.05, min(0.99, score)) |
32 | | - |
33 | | - |
34 | | -def tot_search(spec: ProjectSpec, bundle: Dict[str, Any], beam: int = 3, steps: int = 3) -> SearchResult: |
35 | | - """Offline deliberate search over framings (ToT-style).""" |
36 | | - seeds = [ |
37 | | - f"Frame as claim lattice: {spec.question}", |
38 | | - f"Frame as experiment plan: {spec.question}", |
39 | | - f"Frame as client narrative: {spec.question}", |
40 | | - f"Frame as dialectic tension: {spec.question}", |
41 | | - f"Frame as Kaggle baseline+ablation: {spec.question}", |
| 1 | +"""L3 — Tree Search (True LLM-Based Tree of Thoughts / LATS escalation). |
| 2 | +
|
| 3 | +Replaces hardcoded string-matching with genuine Monte Carlo / Beam Search |
| 4 | +where the LLM actively proposes reasoning branches and evaluates them (LLM-as-a-Judge). |
| 5 | +""" |
| 6 | +from epistemic_forge.models import ProjectSpec, SearchResult, SearchNode, ThoughtProposalsOutput, ThoughtEvaluation |
| 7 | +from epistemic_forge.llm import generate_structured |
| 8 | +from loguru import logger |
| 9 | +from typing import Dict, Any, List |
| 10 | +import uuid |
| 11 | + |
| 12 | +def _generate_thoughts(spec: ProjectSpec, context: str, beam: int) -> List[str]: |
| 13 | + """Uses the LLM to propose diverse reasoning paths (Branches).""" |
| 14 | + messages = [ |
| 15 | + {"role": "system", "content": "You are a divergent thinker in a Tree of Thoughts system. Generate distinct, highly logical ways to approach the user's problem. Do not solve it yet, just propose analytical framings."}, |
| 16 | + {"role": "user", "content": f"Problem: {spec.question}\nContext: {context}\n\nGenerate exactly {beam} distinct analytical approaches."} |
42 | 17 | ] |
43 | | - # Domain bias |
44 | | - d = spec.domain.value |
45 | | - if d == "philosophy": |
46 | | - seeds = [seeds[3], seeds[0], seeds[2]] + seeds |
47 | | - elif d == "kaggle": |
48 | | - seeds = [seeds[4], seeds[1], seeds[0]] + seeds |
49 | | - elif d == "freelance": |
50 | | - seeds = [seeds[2], seeds[0], seeds[1]] + seeds |
51 | | - |
52 | | - nodes: List[SearchNode] = [] |
53 | | - frontier: List[SearchNode] = [] |
54 | | - for i, s in enumerate(seeds[:beam]): |
55 | | - n = SearchNode(id=f"n0_{i}", thought=s, value=_value_label(s, spec), meta={"step": 0}) |
56 | | - nodes.append(n) |
57 | | - frontier.append(n) |
58 | | - |
59 | | - for step in range(1, steps): |
60 | | - candidates: List[SearchNode] = [] |
61 | | - for parent in frontier: |
62 | | - expansions = [ |
63 | | - parent.thought + " → add falsifiers and residual unknowns.", |
64 | | - parent.thought + " → prioritize one high-leverage next action.", |
65 | | - parent.thought + " → stress-test with a steelman objection.", |
66 | | - ] |
67 | | - for j, e in enumerate(expansions): |
68 | | - cid = f"n{step}_{parent.id}_{j}" |
69 | | - child = SearchNode( |
70 | | - id=cid, |
71 | | - thought=e, |
72 | | - value=_value_label(e, spec), |
73 | | - parent_id=parent.id, |
74 | | - meta={"step": step}, |
75 | | - ) |
76 | | - parent.children.append(cid) |
77 | | - nodes.append(child) |
78 | | - candidates.append(child) |
79 | | - # Greedy beam select |
80 | | - candidates.sort(key=lambda x: x.value, reverse=True) |
81 | | - frontier = candidates[:beam] |
82 | | - |
83 | | - best = max(nodes, key=lambda x: x.value) |
84 | | - return SearchResult(best_thought=best.thought, nodes=nodes, mode_used="tot", score=best.value) |
85 | | - |
86 | | - |
87 | | -def lats_polish(spec: ProjectSpec, tot: SearchResult) -> SearchResult: |
88 | | - """Lightweight LATS-like escalation: env/test-aware refinement of best path.""" |
89 | | - # Simulate rollout with "executor feedback" |
90 | | - feedback = [] |
91 | | - if spec.domain.value in {"kaggle", "hybrid"}: |
92 | | - feedback.append("Check CV leakage and metric alignment before fancy models.") |
93 | | - if spec.domain.value in {"freelance", "hybrid"}: |
94 | | - feedback.append("Add acceptance criteria and revision policy.") |
95 | | - if not feedback: |
96 | | - feedback.append("Add one concrete example and one measurable success signal.") |
| 18 | + |
| 19 | + result: ThoughtProposalsOutput = generate_structured( |
| 20 | + messages=messages, |
| 21 | + response_model=ThoughtProposalsOutput, |
| 22 | + model=spec.target_model, |
| 23 | + api_base=spec.api_base |
| 24 | + ) |
| 25 | + return [p.thought_text for p in result.proposals[:beam]] |
97 | 26 |
|
98 | | - improved = tot.best_thought + " | rollout: " + " ".join(feedback) |
99 | | - node = SearchNode( |
100 | | - id="lats_best", |
101 | | - thought=improved, |
102 | | - value=min(0.99, tot.score + 0.08), |
103 | | - parent_id=None, |
104 | | - meta={"mode": "lats_rollout", "feedback": feedback}, |
| 27 | +def _evaluate_thought(spec: ProjectSpec, thought: str) -> float: |
| 28 | + """Uses the LLM as a judge to score the epistemic value of a thought.""" |
| 29 | + messages = [ |
| 30 | + {"role": "system", "content": "You are a strict epistemic judge. Score the given analytical approach from 0.0 to 1.0 based on its logical rigor, falsifiability, and relevance."}, |
| 31 | + {"role": "user", "content": f"Problem: {spec.question}\nProposed Approach: {thought}\n\nEvaluate its epistemic soundness."} |
| 32 | + ] |
| 33 | + |
| 34 | + result: ThoughtEvaluation = generate_structured( |
| 35 | + messages=messages, |
| 36 | + response_model=ThoughtEvaluation, |
| 37 | + model=spec.target_model, |
| 38 | + api_base=spec.api_base |
105 | 39 | ) |
106 | | - nodes = list(tot.nodes) + [node] |
| 40 | + logger.debug(f"Thought evaluated with score {result.epistemic_score}: {result.critique}") |
| 41 | + return result.epistemic_score |
| 42 | + |
| 43 | +def explore(spec: ProjectSpec, bundle: Dict[str, Any], beam: int = 3, steps: int = 2) -> SearchResult: |
| 44 | + """Genuine Beam Search (Tree of Thoughts) over the reasoning space.""" |
| 45 | + |
| 46 | + logger.info(f"L3 Search: Initiating genuine LLM Tree Search (Beam={beam}, Steps={steps})...") |
| 47 | + |
| 48 | + nodes: List[SearchNode] = [] |
| 49 | + |
| 50 | + # Initial state |
| 51 | + current_context = f"Initial constraints for {spec.domain}." |
| 52 | + best_thought_overall = "" |
| 53 | + highest_score = -1.0 |
| 54 | + |
| 55 | + for step in range(steps): |
| 56 | + logger.info(f"L3 Search: Expanding Level {step+1}/{steps}...") |
| 57 | + |
| 58 | + # 1. Propose (Branching) |
| 59 | + proposed_thoughts = _generate_thoughts(spec, current_context, beam) |
| 60 | + |
| 61 | + step_best_thought = "" |
| 62 | + step_highest_score = -1.0 |
| 63 | + |
| 64 | + # 2. Evaluate (Value Function) |
| 65 | + for thought in proposed_thoughts: |
| 66 | + score = _evaluate_thought(spec, thought) |
| 67 | + |
| 68 | + node = SearchNode( |
| 69 | + id=str(uuid.uuid4())[:8], |
| 70 | + thought=thought, |
| 71 | + value=score, |
| 72 | + meta={"step": step} |
| 73 | + ) |
| 74 | + nodes.append(node) |
| 75 | + |
| 76 | + # Track best in step and overall |
| 77 | + if score > step_highest_score: |
| 78 | + step_highest_score = score |
| 79 | + step_best_thought = thought |
| 80 | + |
| 81 | + if score > highest_score: |
| 82 | + highest_score = score |
| 83 | + best_thought_overall = thought |
| 84 | + |
| 85 | + # 3. Select (Beam pruning) - The context for the next step becomes the best thought of this step |
| 86 | + current_context = step_best_thought |
| 87 | + logger.debug(f"Level {step+1} best score: {step_highest_score}") |
| 88 | + |
| 89 | + logger.success(f"L3 Search Complete. Best global epistemic score: {highest_score}") |
| 90 | + |
107 | 91 | return SearchResult( |
108 | | - best_thought=improved, nodes=nodes, mode_used="cascade_tot_lats", score=node.value |
| 92 | + best_thought=best_thought_overall, |
| 93 | + nodes=nodes, |
| 94 | + mode_used="true_llm_tot", |
| 95 | + score=highest_score |
109 | 96 | ) |
110 | 97 |
|
| 98 | +# Fallbacks to keep interface compatible |
| 99 | +def tot_search(spec: ProjectSpec, bundle: Dict[str, Any], beam: int = 3, steps: int = 2) -> SearchResult: |
| 100 | + return explore(spec, bundle, beam, steps) |
111 | 101 |
|
112 | | -def explore(spec: ProjectSpec, bundle: Dict[str, Any], l3_mode: str) -> SearchResult: |
113 | | - tot = tot_search(spec, bundle) |
114 | | - if l3_mode in {"lats", "cascade"}: |
115 | | - return lats_polish(spec, tot) |
116 | | - return tot |
| 102 | +def lats_search(spec: ProjectSpec, bundle: Dict[str, Any], rollouts: int = 3) -> SearchResult: |
| 103 | + """LATS is technically MCTS + Reflection. For now we route to the robust ToT beam search.""" |
| 104 | + return explore(spec, bundle, beam=rollouts, steps=2) |
0 commit comments