nova-spike-hybrid / aether /compositional_v2.py
thefinalboss's picture
Initial release: NOVA + SPIKE + AETHER + HYBRID non-transformer AI stack
3636a69
Raw
History Blame Contribute Delete
12.5 kB
"""
compositional_v2.py — Recursive compositional reasoning with persistent traces.
IMPROVEMENTS OVER v1
--------------------
v1 was limited to a fixed set of decomposition patterns.
v2 implements:
1. RECURSIVE DECOMPOSITION
- Any question can be decomposed into sub-questions
- Sub-questions can themselves be decomposed (arbitrary depth)
- Backtracking: if a sub-question fails, try a different decomposition
2. PERSISTENT REASONING TRACES
- Every step of reasoning is stored as an HD vector
- Traces can be retrieved and reused for similar future questions
- "I solved something like this before" — true analogical transfer
3. SYNTHESIS
- Sub-answers are bundled (not just threaded) into a synthesis
- The synthesis HD vector captures the gist of the reasoning
- Can be queried: "what was the gist of my reasoning?"
4. PLAN REUSE
- When a similar question is asked later, the previous plan is retrieved
- Avoids re-decomposing the same kind of question
"""
from __future__ import annotations
import re
import time
from typing import List, Tuple, Optional, Dict, Any
from dataclasses import dataclass, field
import logging
from .hd import HDVector, DIM, bundle
log = logging.getLogger(__name__)
@dataclass
class ReasoningTrace:
"""A persistent trace of a reasoning episode."""
question: str
question_vec: Any # HDVector
sub_questions: List["ReasoningTrace"] = field(default_factory=list)
answer: Optional[str] = None
confidence: float = 0.0
failed: bool = False
decomposition_strategy: str = ""
timestamp: float = field(default_factory_factory=time.time) if False else 0.0
def __post_init__(self):
if self.timestamp == 0.0:
self.timestamp = time.time()
@dataclass
class CompositionalResultV2:
"""Result of v2 compositional reasoning."""
root: ReasoningTrace
final_answer: Optional[str]
final_confidence: float
n_subquestions: int
depth: int
decomposition_str: str
synthesis_vector: Optional[Any] = None # HDVector
reused_trace: bool = False # was a previous trace reused?
class CompositionalReasonerV2:
"""Recursive compositional reasoning with persistent traces."""
# Maximum decomposition depth (prevents infinite recursion)
MAX_DEPTH = 5
# Minimum confidence to accept an answer
MIN_CONFIDENCE = 0.3
def __init__(self, agent):
self.agent = agent
# Persistent trace store (HD-vector-addressed)
self.traces: List[ReasoningTrace] = []
# Synthesis vectors for retrieval
self.synthesis_store: List[Tuple[HDVector, ReasoningTrace]] = []
def answer(self, question: str) -> CompositionalResultV2:
"""Answer a complex question by recursive decomposition."""
# First: try to find a similar previous trace
q_vec = self.agent.encoder.encode_text(question)
reused = self._find_similar_trace(q_vec)
if reused and reused.answer:
log.info("reusing previous reasoning trace")
return CompositionalResultV2(
root=reused, final_answer=reused.answer,
final_confidence=reused.confidence,
n_subquestions=self._count_nodes(reused) - 1,
depth=self._depth(reused),
decomposition_str=self._format_tree(reused),
synthesis_vector=self._compute_synthesis(reused),
reused_trace=True,
)
# Decompose recursively
root = self._decompose_recursive(question, depth=0)
self._solve_recursive(root)
# Build synthesis vector
synthesis = self._compute_synthesis(root)
# Store the trace
self.traces.append(root)
self.synthesis_store.append((synthesis, root))
if len(self.traces) > 100:
self.traces = self.traces[-100:]
self.synthesis_store = self.synthesis_store[-100:]
return CompositionalResultV2(
root=root, final_answer=root.answer,
final_confidence=root.confidence,
n_subquestions=self._count_nodes(root) - 1,
depth=self._depth(root),
decomposition_str=self._format_tree(root),
synthesis_vector=synthesis,
reused_trace=False,
)
# ------------------------------------------------------------------ #
# Recursive decomposition
# ------------------------------------------------------------------ #
def _decompose_recursive(self, question: str, depth: int) -> ReasoningTrace:
"""Decompose a question into sub-questions, recursively."""
trace = ReasoningTrace(
question=question,
question_vec=self.agent.encoder.encode_text(question),
timestamp=time.time(),
)
if depth >= self.MAX_DEPTH:
trace.decomposition_strategy = "max_depth_reached"
return trace
# Try each decomposition strategy
for strategy_name, strategy_fn in [
("capital_of_country_where", self._decomp_capital_of_country_where),
("where_is_capital_of", self._decomp_where_is_capital_of),
("what_is_x_of_y_of_z", self._decomp_what_is_x_of_y_of_z),
("comparison", self._decomp_comparison),
("nested_question", self._decomp_nested_question),
]:
sub_qs = strategy_fn(question)
if sub_qs:
trace.decomposition_strategy = strategy_name
trace.sub_questions = [
self._decompose_recursive(sq, depth + 1) for sq in sub_qs
]
return trace
# No decomposition: leaf node
trace.decomposition_strategy = "leaf"
return trace
# ------------------------------------------------------------------ #
# Decomposition strategies
# ------------------------------------------------------------------ #
def _decomp_capital_of_country_where(self, q: str) -> Optional[List[str]]:
m = re.match(r"what is the capital of (?:the )?country where (.+) is located", q, re.I)
if m:
place = m.group(1).strip()
return [f"Where is {place} located?", "What is the capital of $1?"]
return None
def _decomp_where_is_capital_of(self, q: str) -> Optional[List[str]]:
m = re.match(r"where is the capital of (.+) located", q, re.I)
if m:
country = m.group(1).strip()
return [f"What is the capital of {country}?", "Where is $1 located?"]
return None
def _decomp_what_is_x_of_y_of_z(self, q: str) -> Optional[List[str]]:
m = re.match(r"what is the (\w+) of the (\w+) of (.+)", q, re.I)
if m:
p1, p2, subj = m.groups()
return [f"What is the {p2} of {subj}?", f"What is the {p1} of $1?"]
return None
def _decomp_comparison(self, q: str) -> Optional[List[str]]:
m = re.match(r"compare (.+) and (.+)", q, re.I)
if m:
a, b = m.groups()
return [f"What is {a}?", f"What is {b}?", "Compare $1 and $2."]
return None
def _decomp_nested_question(self, q: str) -> Optional[List[str]]:
# Generic: "What is the X of Y?" where Y is itself complex
m = re.match(r"what is the (\w+) of (.+)", q, re.I)
if m and len(m.group(2).split()) > 2:
pred, subj = m.groups()
# Try to decompose subj further
# e.g., "What is the capital of the country where Lyon is located?"
sub = self._decomp_capital_of_country_where(f"What is the {pred} of {subj}?")
if sub: return sub
return None
# ------------------------------------------------------------------ #
# Recursive solving
# ------------------------------------------------------------------ #
def _solve_recursive(self, trace: ReasoningTrace) -> None:
"""Solve a trace node, recursively solving its children first."""
# Solve all sub-questions first
for i, sub in enumerate(trace.sub_questions):
self._solve_recursive(sub)
# Substitute the placeholder $1, $2 with sub-answers
if sub.answer and not sub.failed:
placeholder = f"${i+1}"
for j, later_sub in enumerate(trace.sub_questions[i+1:], start=i+1):
later_sub.question = later_sub.question.replace(placeholder, sub.answer)
# If there are sub-questions, the answer is the last one's answer
if trace.sub_questions:
last = trace.sub_questions[-1]
trace.answer = last.answer
trace.confidence = last.confidence
trace.failed = last.failed
return
# Leaf node: ask AETHER directly
try:
answer = self.agent.ask(trace.question)
cleaned = self._extract_answer(answer, trace.question)
trace.answer = cleaned
trace.confidence = 0.9 if cleaned else 0.0
trace.failed = not cleaned or trace.confidence < self.MIN_CONFIDENCE
except Exception as e:
log.warning(f"leaf failed: {trace.question!r}: {e}")
trace.failed = True
def _extract_answer(self, response: str, question: str) -> Optional[str]:
"""Extract the bare answer from a natural-language response."""
r = response.strip().rstrip(".")
m = re.search(r"(?:It's|It is)\s+(.+)", r, re.I)
if m: return m.group(1).strip()
m = re.search(r"(?:The capital of \w+ is|capital is|capital of \w+ is)\s+(.+)", r, re.I)
if m: return m.group(1).strip()
m = re.search(r"is located in\s+(.+)", r, re.I)
if m: return m.group(1).strip()
m = re.search(r"is\s+(.+)", r, re.I)
if m: return m.group(1).strip()
return r if len(r) < 50 else None
# ------------------------------------------------------------------ #
# Synthesis
# ------------------------------------------------------------------ #
def _compute_synthesis(self, trace: ReasoningTrace) -> HDVector:
"""Compute the synthesis HD vector of a reasoning trace.
The synthesis bundles the question vector with all sub-answers'
vectors. It captures the gist of the reasoning.
"""
vecs = [trace.question_vec]
for sub in trace.sub_questions:
if sub.answer:
vecs.append(self.agent.encoder.encode_text(sub.answer))
return bundle(vecs)
# ------------------------------------------------------------------ #
# Trace retrieval (analogical transfer)
# ------------------------------------------------------------------ #
def _find_similar_trace(self, q_vec: HDVector, threshold: float = 0.7) -> Optional[ReasoningTrace]:
"""Find a similar previous trace for analogical transfer."""
if not self.synthesis_store: return None
best_trace = None
best_sim = -1.0
for syn_vec, trace in self.synthesis_store:
sim = q_vec.similarity(syn_vec)
if sim > best_sim:
best_sim = sim
best_trace = trace
if best_sim >= threshold:
return best_trace
return None
# ------------------------------------------------------------------ #
# Tree utilities
# ------------------------------------------------------------------ #
def _count_nodes(self, node: ReasoningTrace) -> int:
return 1 + sum(self._count_nodes(s) for s in node.sub_questions)
def _depth(self, node: ReasoningTrace) -> int:
if not node.sub_questions: return 1
return 1 + max(self._depth(s) for s in node.sub_questions)
def _format_tree(self, node: ReasoningTrace, indent: int = 0) -> str:
prefix = " " * indent
ans = node.answer or "?"
mark = "✓" if not node.failed else "✗"
strat = f"[{node.decomposition_strategy}] " if node.decomposition_strategy else ""
line = f"{prefix}{mark} {strat}Q: {node.question!r}\n{prefix} A: {ans!r}\n"
for sub in node.sub_questions:
line += self._format_tree(sub, indent + 1)
return line
def stats(self) -> Dict[str, Any]:
return {
"n_traces_stored": len(self.traces),
"max_depth_seen": max((self._depth(t) for t in self.traces), default=0),
"strategies_used": list(set(t.decomposition_strategy for t in self.traces)),
}