diff --git a/.gitignore b/.gitignore index cc452aa4..225d1f3f 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ logs/sentinel/ # Agent Core — memory persistence .disha/ + +# Claude Code internal state +.claude/ diff --git a/ai-platform/backend/app/collaboration/coordinator.py b/ai-platform/backend/app/collaboration/coordinator.py index 9f2045e0..e066fcb8 100644 --- a/ai-platform/backend/app/collaboration/coordinator.py +++ b/ai-platform/backend/app/collaboration/coordinator.py @@ -74,6 +74,9 @@ def get_capable_agents(self, capability: str) -> list: if capability in node.capabilities and node.status != "offline" ] + ITERATION_THRESHOLD = 0.4 # re-run if consensus agreement falls below this + MAX_ITERATIONS = 3 # cap iterations to prevent infinite loops + async def collaborative_investigate( self, target: str, @@ -85,11 +88,12 @@ async def collaborative_investigate( Flow: 1. Coordinator broadcasts task to all agents - 2. Agents claim subtasks based on capabilities - 3. Agents execute and share results - 4. Review phase: agents critique each other - 5. Consensus: agents vote on final assessment - 6. Iterate if needed + 2. Agents execute in parallel + 3. Peer review: agents score each other's results + 4. Consensus: confidence-weighted vote on risk assessment + 5. If consensus is too low, iterate with dissenting agents + re-running with additional context from the first round + 6. Compile final result """ conversation = self.router.create_conversation(topic=target) context = context or {} @@ -109,14 +113,53 @@ async def collaborative_investigate( self.router.send(task_msg) conversation.add_message(task_msg) - # Phase 2: Parallel execution by all capable agents + # Phase 2: Parallel execution results = await self._parallel_execute(target, context) - # Phase 3: Share results for peer review + # Phase 3: Peer review review_results = await self._peer_review(results, conversation) - # Phase 4: Consensus building + # Phase 4: Consensus + iterative refinement consensus = self._build_consensus(results, review_results) + iteration = 0 + + while ( + not consensus.get("consensus_reached", False) + and consensus.get("agreement_score", 1.0) < self.ITERATION_THRESHOLD + and iteration < self.MAX_ITERATIONS + ): + iteration += 1 + logger.info( + "consensus_below_threshold_iterating", + target=target, + iteration=iteration, + agreement=round(consensus.get("agreement_score", 0), 3), + ) + + # Identify low-confidence agents and re-run them with shared context + low_conf_agents = self._identify_dissenting_agents(results, review_results) + if not low_conf_agents: + break + + enriched_context = { + **context, + "prior_round": iteration, + "peer_findings": { + name: res.get("entities", [])[:5] + for name, res in results.items() + if isinstance(res, dict) and name not in low_conf_agents + }, + } + + # Re-run only the dissenting agents + refined = await self._selective_execute(low_conf_agents, target, enriched_context) + results.update(refined) + + # Rebuild consensus with updated results + review_results = await self._peer_review(results, conversation) + consensus = self._build_consensus(results, review_results) + + consensus["iterations"] = iteration # Phase 5: Compile final result final_result = self._compile_results( @@ -129,12 +172,69 @@ async def collaborative_investigate( "collaborative_investigation_complete", target=target, agents_involved=len(results), + iterations=iteration, consensus_score=consensus.get("score", 0), + agreement=consensus.get("agreement_score", 0), risk_score=final_result.get("risk_score", 0), ) return final_result + def _identify_dissenting_agents(self, results: dict, reviews: dict) -> list[str]: + """Return names of agents whose results received the lowest peer review scores.""" + avg_scores: dict[str, list[float]] = {} + for reviewer_reviews in reviews.values(): + for name, score in reviewer_reviews.items(): + avg_scores.setdefault(name, []).append(score) + + scored = { + name: sum(scores) / len(scores) + for name, scores in avg_scores.items() + if scores + } + if not scored: + return [] + + # Return the bottom 50% of agents by peer-review score + sorted_agents = sorted(scored, key=scored.get) + cutoff = max(1, len(sorted_agents) // 2) + return sorted_agents[:cutoff] + + async def _selective_execute( + self, agent_names: list[str], target: str, context: dict + ) -> dict: + """Re-run a specific subset of agents.""" + selected = [(name, node) for name, node in self.nodes.items() if name in agent_names] + if not selected: + return {} + + results = {} + + async def run_agent(name, node): + start = time.time() + try: + result = await node.agent.run(target, context) + node.tasks_completed += 1 + node.total_time += time.time() - start + node.last_active = time.time() + return name, result + except Exception as e: + logger.error("selective_agent_execution_failed", agent=name, error=str(e)) + return name, {"error": str(e), "status": "failed"} + + agent_results = await asyncio.gather( + *[run_agent(name, node) for name, node in selected], + return_exceptions=True, + ) + + for item in agent_results: + if isinstance(item, Exception): + continue + name, result = item + results[name] = result + + return results + async def _parallel_execute(self, target: str, context: dict) -> dict: """Execute all available agents in parallel.""" results = {} diff --git a/cognitive-engine/__init__.py b/cognitive-engine/__init__.py new file mode 100644 index 00000000..6bfb31f8 --- /dev/null +++ b/cognitive-engine/__init__.py @@ -0,0 +1,11 @@ +""" +DISHA-MIND — Cognitive Engine Package. + +Entry point for the 7-stage cognitive loop, three-layer memory architecture, +multi-agent deliberation, hybrid reasoning, and quantum-inspired decisions. +""" + +from cognitive_engine.cognitive_loop import CognitiveEngine, CognitiveState + +__all__ = ["CognitiveEngine", "CognitiveState"] +__version__ = "1.0.0" diff --git a/cognitive-engine/agents/__init__.py b/cognitive-engine/agents/__init__.py new file mode 100644 index 00000000..f892f9d2 --- /dev/null +++ b/cognitive-engine/agents/__init__.py @@ -0,0 +1,17 @@ +""" +cognitive-engine/agents — Multi-Agent Deliberation for the DISHA Cognitive Architecture. + +This subpackage implements the three-agent deliberation layer that sits between +raw reasoning hypotheses and final action selection: + + Planner → strategic goal decomposition + Executor → concrete step-by-step action plan + Critic → quality gate and risk assessment + Vote → confidence-weighted consensus + +The AgentDeliberator class orchestrates all three and returns a consensus dict. +""" + +from cognitive_engine.agents.deliberation import AgentDeliberator + +__all__ = ["AgentDeliberator"] diff --git a/cognitive-engine/agents/deliberation.py b/cognitive-engine/agents/deliberation.py new file mode 100644 index 00000000..acfdd263 --- /dev/null +++ b/cognitive-engine/agents/deliberation.py @@ -0,0 +1,475 @@ +""" +AgentDeliberator — Multi-Agent Deliberation for the DISHA Cognitive Engine. + +Implements a three-agent deliberation architecture: + + 1. Planner Agent — Breaks the goal into ordered strategic steps. + 2. Executor Agent — Converts the plan into a concrete, immediately actionable step. + 3. Critic Agent — Reviews quality, flags risks, and assigns a confidence score. + +The three agent outputs are combined via confidence-weighted voting to produce a +consensus recommendation. Dissenting views are preserved for metacognitive review. + +Role in architecture: + AgentDeliberator is called during the _deliberate phase of the cognitive loop. + It takes the current CognitiveState (with hypotheses and working memory) and + returns a structured deliberation dict used to select the final action. +""" + +from __future__ import annotations + +import asyncio +import re +import time +from typing import TYPE_CHECKING, Any + +import structlog + +log = structlog.get_logger(__name__) + +if TYPE_CHECKING: + from cognitive_engine.cognitive_loop import CognitiveState + + +# --------------------------------------------------------------------------- +# Heuristic knowledge base used by rule-based agents +# (Replace with real LLM calls by swapping _call_llm stubs below) +# --------------------------------------------------------------------------- + +# Intent → strategic goal templates +_STRATEGIC_TEMPLATES: dict[str, list[str]] = { + "question": [ + "Identify the core information need", + "Search episodic and semantic memory for relevant context", + "Formulate a precise, accurate answer", + "Validate answer against known constraints", + "Deliver response with appropriate confidence disclosure", + ], + "command": [ + "Parse the command into discrete sub-tasks", + "Assess feasibility and resource requirements", + "Execute sub-tasks in dependency order", + "Verify each step succeeded before proceeding", + "Report completion status with outcomes", + ], + "creative": [ + "Understand the creative brief and constraints", + "Generate multiple divergent ideas", + "Evaluate ideas against the brief", + "Synthesize the strongest elements", + "Produce a coherent creative output", + ], + "analysis": [ + "Define the scope and key dimensions of analysis", + "Gather relevant data from memory and context", + "Apply analytical framework (compare, contrast, evaluate)", + "Draw evidence-based conclusions", + "Present findings with confidence intervals", + ], + "conversation": [ + "Identify the social/emotional register of the exchange", + "Recall relevant context from prior conversation", + "Formulate a contextually appropriate response", + "Check for empathy and tone alignment", + "Deliver the response naturally", + ], + "default": [ + "Clarify the user's underlying goal", + "Gather relevant context from memory", + "Formulate a response or action", + "Validate quality and appropriateness", + "Execute and confirm", + ], +} + +# Executor action types keyed by intent +_EXECUTOR_ACTIONS: dict[str, str] = { + "question": "retrieve_and_synthesize", + "command": "execute_command_pipeline", + "creative": "generate_creative_artifact", + "analysis": "perform_structured_analysis", + "conversation": "generate_conversational_response", + "default": "respond_with_clarification", +} + +# Critic risk heuristics: keywords → risk flags +_RISK_KEYWORDS: list[tuple[str, str]] = [ + (r"\bharm|danger|risk|attack|exploit\b", "potential_harm_detected"), + (r"\buncertain|unclear|ambiguous|vague\b", "low_clarity_input"), + (r"\bprivate|secret|confidential|personal\b", "privacy_concern"), + (r"\bnever|always|everyone|nobody\b", "absolute_claim_detected"), + (r"\bfake|false|wrong|incorrect\b", "potential_misinformation"), + (r"\bdeadline|urgent|emergency|immediately\b", "urgency_pressure"), +] + + +# --------------------------------------------------------------------------- +# LLM stub (plug in real LLM here) +# --------------------------------------------------------------------------- + +async def _call_llm(prompt: str, role: str = "assistant") -> str: + """ + Stub for real LLM API calls. + + TODO: Replace this stub with actual LLM calls, e.g.: + import openai + response = await openai.ChatCompletion.acreate( + model="gpt-4o", + messages=[{"role": "system", "content": role}, {"role": "user", "content": prompt}], + ) + return response.choices[0].message.content + + Currently returns None to signal that rule-based fallback should be used. + """ + return "" # Empty string → use rule-based fallback + + +# --------------------------------------------------------------------------- +# AgentDeliberator +# --------------------------------------------------------------------------- + + +class AgentDeliberator: + """ + Runs three cognitive agents in parallel and produces a consensus decision. + + Each agent is an async method that takes a CognitiveState and returns: + { + "agent": str, + "recommendation": str, + "confidence": float, + "reasoning": str, + "concerns": list[str], + "timestamp": float, + } + + The _vote method performs confidence-weighted selection and preserves + any dissenting minority views. + """ + + def __init__(self) -> None: + log.info("agent_deliberator.initialized") + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + async def deliberate(self, state: "CognitiveState") -> dict[str, Any]: + """ + Run Planner, Executor, and Critic agents concurrently, then vote. + + Args: + state: Current CognitiveState from the cognitive loop. + + Returns: + { + "winner": dict, # Winning agent opinion + "all_opinions": list[dict], # All three agent outputs + "consensus_confidence": float, + "dissenting_view": dict | None, + "recommended_action": str, + "timestamp": float, + } + """ + log.info( + "agent_deliberator.deliberating", + session_id=state.session_id, + turn=state.turn, + intent=state.intent, + ) + + # Run all three agents in parallel + planner_out, executor_out, critic_out = await asyncio.gather( + self._planner_agent(state), + self._executor_agent(state), + self._critic_agent(state), + ) + + all_opinions = [planner_out, executor_out, critic_out] + result = self._vote(all_opinions) + + log.info( + "agent_deliberator.consensus_reached", + winner=result["winner"]["agent"], + confidence=result["consensus_confidence"], + dissent=result["dissenting_view"] is not None, + ) + return result + + # ------------------------------------------------------------------ + # Agent methods + # ------------------------------------------------------------------ + + async def _planner_agent(self, state: "CognitiveState") -> dict[str, Any]: + """ + Strategic Planner: decompose the intent into ordered goal steps. + + TODO: Replace rule-based logic with LLM call: + prompt = f"You are a strategic planner. Given intent '{state.intent}' and + context {state.context}, decompose this into 3-5 ordered steps..." + response = await _call_llm(prompt, role="strategic planner") + """ + intent = state.intent or "default" + steps = _STRATEGIC_TEMPLATES.get(intent, _STRATEGIC_TEMPLATES["default"]) + + # Adjust confidence based on how well the intent was classified + base_confidence = 0.75 + if state.context.get("intent_confidence", 0.0) > 0.8: + base_confidence = 0.85 + elif state.context.get("intent_confidence", 0.0) < 0.4: + base_confidence = 0.55 + + # Factor in hypothesis quality + if state.hypotheses: + avg_hyp_confidence = sum( + h.get("confidence", 0.5) for h in state.hypotheses + ) / len(state.hypotheses) + base_confidence = (base_confidence + avg_hyp_confidence) / 2 + + concerns: list[str] = [] + if len(steps) > 4: + concerns.append("multi_step_plan_may_require_clarification") + if state.context.get("uncertainty_level", 0) > 0.6: + concerns.append("high_uncertainty_may_affect_plan_execution") + + plan_text = " → ".join(f"Step {i+1}: {s}" for i, s in enumerate(steps)) + recommendation = f"Execute {len(steps)}-step plan: {plan_text}" + + return { + "agent": "planner", + "recommendation": recommendation, + "plan_steps": steps, + "confidence": round(base_confidence, 3), + "reasoning": ( + f"Intent classified as '{intent}'. Applied strategic template with " + f"{len(steps)} steps. Confidence adjusted for input clarity." + ), + "concerns": concerns, + "timestamp": time.time(), + } + + async def _executor_agent(self, state: "CognitiveState") -> dict[str, Any]: + """ + Executor Agent: produce a concrete, immediately actionable step. + + TODO: Replace rule-based logic with LLM call: + prompt = f"You are an executor agent. Given the plan {plan} and current + context {state.context}, what is the single most important + action to take RIGHT NOW? Be specific and concrete." + response = await _call_llm(prompt, role="executor agent") + """ + intent = state.intent or "default" + action_type = _EXECUTOR_ACTIONS.get(intent, _EXECUTOR_ACTIONS["default"]) + + # Derive concrete step from best hypothesis + best_hyp: dict[str, Any] = {} + if state.hypotheses: + best_hyp = max(state.hypotheses, key=lambda h: h.get("confidence", 0)) + + immediate_action: str + if best_hyp: + hyp_text = best_hyp.get("hypothesis", "") + immediate_action = ( + f"[{action_type.upper()}] {hyp_text[:200]}" + if hyp_text + else f"[{action_type.upper()}] Process input using {intent} handling pipeline" + ) + else: + immediate_action = ( + f"[{action_type.upper()}] Process input using {intent} handling pipeline" + ) + + # Confidence is driven by hypothesis confidence + working memory richness + wm_richness = min(1.0, len(state.working_memory) / 8) + hyp_conf = best_hyp.get("confidence", 0.6) if best_hyp else 0.6 + confidence = round((hyp_conf * 0.7) + (wm_richness * 0.3), 3) + + concerns: list[str] = [] + if not state.working_memory: + concerns.append("insufficient_context_in_working_memory") + if hyp_conf < 0.5: + concerns.append("low_confidence_hypothesis_may_produce_poor_output") + + return { + "agent": "executor", + "recommendation": immediate_action, + "action_type": action_type, + "confidence": confidence, + "reasoning": ( + f"Selected '{action_type}' action pipeline based on intent '{intent}'. " + f"Immediate step derived from best hypothesis (conf={hyp_conf:.2f}). " + f"Working memory richness={wm_richness:.2f}." + ), + "concerns": concerns, + "timestamp": time.time(), + } + + async def _critic_agent(self, state: "CognitiveState") -> dict[str, Any]: + """ + Critic Agent: quality-check the proposed plan/action, identify risks. + + TODO: Replace rule-based logic with LLM call: + prompt = f"You are a critical reviewer. Evaluate the proposed action + '{proposed_action}' for quality, risks, and confidence. + Input: '{state.raw_input}'. Context: {state.context}." + response = await _call_llm(prompt, role="critic agent") + """ + raw = state.raw_input or "" + + # Scan for risk keywords + risk_flags: list[str] = [] + for pattern, flag in _RISK_KEYWORDS: + if re.search(pattern, raw, re.IGNORECASE): + risk_flags.append(flag) + + # Evaluate reasoning quality + quality_signals: list[str] = [] + confidence_penalty = 0.0 + + if state.hypotheses and len(state.hypotheses) >= 3: + quality_signals.append("multi_modal_reasoning_active") + else: + confidence_penalty += 0.1 + quality_signals.append("insufficient_hypothesis_diversity") + + if state.working_memory: + quality_signals.append("working_memory_populated") + else: + confidence_penalty += 0.05 + quality_signals.append("empty_working_memory") + + if state.context.get("entities"): + quality_signals.append("entities_extracted") + else: + quality_signals.append("no_entities_detected") + + # Base critic confidence: start high, deduct for each risk + base_confidence = 0.80 - (len(risk_flags) * 0.08) - confidence_penalty + + # Cross-check against hypotheses + if state.hypotheses: + hyp_confs = [h.get("confidence", 0.5) for h in state.hypotheses] + avg_hyp = sum(hyp_confs) / len(hyp_confs) + base_confidence = (base_confidence + avg_hyp) / 2 + + base_confidence = max(0.1, min(0.99, base_confidence)) + + all_concerns = risk_flags + [ + s for s in quality_signals if s.startswith("insufficient") or s.startswith("empty") + ] + + recommendation: str + if base_confidence >= 0.7: + recommendation = "APPROVE — quality checks passed, proceed with action" + elif base_confidence >= 0.5: + recommendation = "CONDITIONAL_APPROVE — proceed with caution, monitor outcomes" + else: + recommendation = "REJECT — insufficient confidence, request clarification" + + return { + "agent": "critic", + "recommendation": recommendation, + "quality_signals": quality_signals, + "risk_flags": risk_flags, + "confidence": round(base_confidence, 3), + "reasoning": ( + f"Scanned input for {len(_RISK_KEYWORDS)} risk patterns, " + f"found {len(risk_flags)} flags. " + f"Quality signals: {', '.join(quality_signals) or 'none'}. " + f"Base confidence after deductions: {base_confidence:.2f}." + ), + "concerns": all_concerns, + "timestamp": time.time(), + } + + # ------------------------------------------------------------------ + # Voting mechanism + # ------------------------------------------------------------------ + + def _vote(self, opinions: list[dict[str, Any]]) -> dict[str, Any]: + """ + Confidence-weighted vote across agent opinions. + + The winner is the agent with the highest confidence score. If the + critic REJECTS (confidence < 0.5), it acts as a veto regardless of + other scores. The dissenting view is the opinion furthest from the + winner in confidence space. + + Args: + opinions: List of agent output dicts (each with 'confidence'). + + Returns: + { + "winner": dict, + "all_opinions": list[dict], + "consensus_confidence": float, + "dissenting_view": dict | None, + "recommended_action": str, + "timestamp": float, + } + """ + if not opinions: + return { + "winner": {}, + "all_opinions": [], + "consensus_confidence": 0.0, + "dissenting_view": None, + "recommended_action": "no_action", + "timestamp": time.time(), + } + + # Check for critic veto + critic_opinion = next((o for o in opinions if o.get("agent") == "critic"), None) + if critic_opinion and critic_opinion.get("confidence", 1.0) < 0.5: + # Critic veto: executor's action is blocked + log.warning( + "agent_deliberator.critic_veto", + confidence=critic_opinion.get("confidence"), + concerns=critic_opinion.get("concerns"), + ) + # Override: use critic's conservative recommendation + executor_opinion = next((o for o in opinions if o.get("agent") == "executor"), None) + dissent = executor_opinion if executor_opinion else None + + total_weight = sum(o.get("confidence", 0.5) for o in opinions) + consensus = total_weight / len(opinions) + + return { + "winner": critic_opinion, + "all_opinions": opinions, + "consensus_confidence": round(consensus, 3), + "dissenting_view": dissent, + "recommended_action": "request_clarification_or_abort", + "veto_active": True, + "timestamp": time.time(), + } + + # Normal weighted vote + winner = max(opinions, key=lambda o: o.get("confidence", 0.0)) + + # Consensus confidence = weighted average of all agents + total_weight = sum(o.get("confidence", 0.0) for o in opinions) + consensus_conf = total_weight / len(opinions) + + # Dissenting view: the opinion with the greatest deviation from winner + winner_conf = winner.get("confidence", 0.0) + others = [o for o in opinions if o is not winner] + dissent: dict[str, Any] | None = None + if others: + dissent = max(others, key=lambda o: abs(o.get("confidence", 0.0) - winner_conf)) + # Only preserve as dissent if the gap is significant + if abs(dissent.get("confidence", 0.0) - winner_conf) < 0.1: + dissent = None # Opinions are too close to matter + + # Extract a clean recommended_action string + recommended_action = winner.get("action_type") or winner.get("recommendation", "respond") + + return { + "winner": winner, + "all_opinions": opinions, + "consensus_confidence": round(consensus_conf, 3), + "dissenting_view": dissent, + "recommended_action": recommended_action, + "veto_active": False, + "timestamp": time.time(), + } diff --git a/cognitive-engine/api/__init__.py b/cognitive-engine/api/__init__.py new file mode 100644 index 00000000..5f151df4 --- /dev/null +++ b/cognitive-engine/api/__init__.py @@ -0,0 +1 @@ +"""Cognitive Engine API package.""" diff --git a/cognitive-engine/api/routes.py b/cognitive-engine/api/routes.py new file mode 100644 index 00000000..4ac1a7fd --- /dev/null +++ b/cognitive-engine/api/routes.py @@ -0,0 +1,234 @@ +""" +Cognitive Engine API — FastAPI routes. + +Exposes: + GET /health — liveness check + POST /process — single-turn synchronous cognitive cycle + GET /introspect/{sid} — full reasoning trace for a session + WS /cognitive/stream/{session_id} + — real-time stage-by-stage event stream +""" + +from __future__ import annotations + +import asyncio +import json +import uuid +from typing import Optional + +import structlog +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from pydantic import BaseModel + +from cognitive_engine.cognitive_loop import CognitiveEngine + +logger = structlog.get_logger("cognitive_api") + +router = APIRouter() +_engine = CognitiveEngine() + +# ── REST schemas ─────────────────────────────────────────────────────────────── + + +class ProcessRequest(BaseModel): + input: str + session_id: Optional[str] = None + + +class ProcessResponse(BaseModel): + session_id: str + turn: int + intent: str + confidence: float + action: dict + reflection: Optional[dict] + stage_durations: dict + + +# ── REST endpoints ───────────────────────────────────────────────────────────── + + +@router.get("/health") +async def health(): + return {"status": "ok", "engine": "DISHA-MIND", "version": "1.0.0"} + + +@router.post("/process", response_model=ProcessResponse) +async def process_input(req: ProcessRequest): + """ + Run a full 7-stage cognitive cycle synchronously. + Returns the completed CognitiveState as JSON. + """ + state = await _engine.process(req.input, session_id=req.session_id) + return ProcessResponse( + session_id=state.session_id, + turn=state.turn, + intent=state.intent, + confidence=state.confidence, + action=state.action or {}, + reflection=state.reflection, + stage_durations=state.stage_durations, + ) + + +@router.get("/introspect/{session_id}") +async def introspect(session_id: str): + """Return the full reasoning trace for a session.""" + return _engine.get_introspection_report(session_id) + + +@router.get("/sessions") +async def sessions(): + return {"session_ids": _engine.get_session_ids()} + + +# ── WebSocket streaming ──────────────────────────────────────────────────────── + + +STAGE_ORDER = [ + "perceiving", + "attending", + "reasoning", + "deliberating", + "acting", + "reflecting", + "consolidating", +] + + +@router.websocket("/cognitive/stream/{session_id}") +async def cognitive_stream(websocket: WebSocket, session_id: str): + """ + Stream each cognitive stage as it completes. + + Client sends: {"input": "...", "session_id": "..."} + Server emits: {"stage": "perceiving", "data": {...}} × 7 stages + {"stage": "complete", "data": {...}} + {"stage": "error", "data": {"error": "..."}} + """ + await websocket.accept() + log = logger.bind(session=session_id) + log.info("ws_connected") + + try: + while True: + raw = await websocket.receive_text() + payload = json.loads(raw) + user_input = payload.get("input", "").strip() + if not user_input: + continue + + sid = payload.get("session_id", session_id) or str(uuid.uuid4()) + + # Run cognitive cycle with per-stage callbacks + await _stream_cognitive_cycle(websocket, user_input, sid) + + except WebSocketDisconnect: + log.info("ws_disconnected") + except Exception as exc: + log.error("ws_error", error=str(exc)) + try: + await websocket.send_json({"stage": "error", "data": {"error": str(exc)}}) + except Exception: + pass + + +async def _stream_cognitive_cycle(ws: WebSocket, raw_input: str, session_id: str): + """ + Run the full cognitive cycle and stream events after each stage. + Monkey-patches the engine's stage methods to intercept results mid-flight. + """ + import time + + from cognitive_engine.cognitive_loop import CognitiveState + + turn = len(_engine._traces.get(session_id, [])) + state = CognitiveState(session_id=session_id, turn=turn, raw_input=raw_input) + + stage_map = { + "perceive": ("perceiving", _engine._perceive), + "attend": ("attending", _engine._attend), + "reason": ("reasoning", _engine._reason), + "deliberate": ("deliberating", _engine._deliberate), + "act": ("acting", _engine._act), + "reflect": ("reflecting", _engine._reflect), + "consolidate": ("consolidating", _engine._consolidate), + } + + import structlog + log = structlog.get_logger("cognitive_stream").bind(session=session_id, turn=turn) + log.info("stream_cycle_start", input_preview=raw_input[:80]) + + for internal_name, (stage_name, stage_fn) in stage_map.items(): + t0 = time.perf_counter() + try: + await stage_fn(state) + except Exception as exc: + log.error("stage_failed", stage=stage_name, error=str(exc)) + state.learning_triggers.append(f"stage_error:{internal_name}") + + duration = round(time.perf_counter() - t0, 4) + state.stage_durations[internal_name] = duration + + # Build stage payload + payload = _stage_payload(stage_name, state) + payload["duration"] = duration + + await ws.send_json({"stage": stage_name, "data": payload}) + await asyncio.sleep(0) # yield to event loop + + _engine._traces.setdefault(session_id, []).append(state) + + await ws.send_json({ + "stage": "complete", + "data": { + "session_id": session_id, + "turn": turn, + "intent": state.intent, + "confidence": state.confidence, + "action": state.action, + "reflection": state.reflection, + "stage_durations": state.stage_durations, + }, + }) + + +def _stage_payload(stage: str, state) -> dict: + """Extract the relevant fields from state for each stage event.""" + if stage == "perceiving": + return {"intent": state.intent, "entities": state.entities, "uncertainty": state.uncertainty} + if stage == "attending": + return { + "working_memory": [ + {"content": str(m.get("content", ""))[:80], "relevance": m.get("relevance", 0)} + if isinstance(m, dict) else {"content": str(m)[:80], "relevance": 0.5} + for m in state.working_memory[:8] + ], + "recalled_episodes": len(state.recalled_episodes), + "recalled_concepts": len(state.recalled_concepts), + } + if stage == "reasoning": + return { + "hypotheses": state.hypotheses, + "selected": state.selected_hypothesis, + } + if stage == "deliberating": + return { + "all_opinions": state.agent_deliberations, + "consensus": state.consensus, + "dissenting_view": state.dissenting_view, + } + if stage == "acting": + return {"action": state.action, "confidence": state.confidence} + if stage == "reflecting": + return { + "quality": (state.reflection or {}).get("quality", 0), + "triggers": state.learning_triggers, + "factors": (state.reflection or {}).get("factors", {}), + } + if stage == "consolidating": + return { + "episodes_stored": state.consolidated_episodes, + "concepts_learned": state.new_concepts_learned, + } + return {} diff --git a/cognitive-engine/cognitive_loop.py b/cognitive-engine/cognitive_loop.py new file mode 100644 index 00000000..6237cd6d --- /dev/null +++ b/cognitive-engine/cognitive_loop.py @@ -0,0 +1,382 @@ +""" +DISHA-MIND Cognitive Loop Engine. + +The central cognitive architecture of the Disha AGI platform. +Implements a 7-stage cognitive cycle inspired by theories of human cognition: + + Perceive → Attend → Reason → Deliberate → Act → Reflect → Consolidate + +Each stage produces a structured artifact captured in CognitiveState. +The full trace is available for real-time introspection and debugging. + +This is NOT a prompt-routing wrapper. Each stage performs real computational +work whose result the next stage reads as its primary input. +""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import structlog + +from cognitive_engine.memory.memory_manager import MemoryManager +from cognitive_engine.agents.deliberation import AgentDeliberator +from cognitive_engine.intelligence.hybrid_reasoner import HybridReasoner +from cognitive_engine.intelligence.quantum_decision import QuantumDecisionEngine +from cognitive_engine.intelligence.goal_engine import GoalEngine + +logger = structlog.get_logger("cognitive_loop") + +# ── Cognitive State ──────────────────────────────────────────────────────────── + + +@dataclass +class CognitiveState: + """ + The typed artifact that flows through each cognitive stage. + + Accumulated stage-by-stage: each stage reads the previous state and + writes its own fields before passing the object forward. + """ + session_id: str + turn: int + raw_input: str + + # Stage 1 — Perceive + intent: str = "" + entities: list[str] = field(default_factory=list) + uncertainty: float = 0.5 # 0 = fully certain, 1 = fully uncertain + + # Stage 2 — Attend + working_memory: list[dict] = field(default_factory=list) + recalled_episodes: list[dict] = field(default_factory=list) + recalled_concepts: list[dict] = field(default_factory=list) + + # Stage 3 — Reason + hypotheses: list[dict] = field(default_factory=list) + selected_hypothesis: dict | None = None + + # Stage 4 — Deliberate + agent_deliberations: list[dict] = field(default_factory=list) + consensus: dict | None = None + dissenting_view: dict | None = None + + # Stage 5 — Act + action: dict | None = None + confidence: float = 0.0 + + # Stage 6 — Reflect + reflection: dict | None = None + learning_triggers: list[str] = field(default_factory=list) + + # Stage 7 — Consolidate + consolidated_episodes: int = 0 + new_concepts_learned: int = 0 + + # Metadata + stage_durations: dict[str, float] = field(default_factory=dict) + timestamp: float = field(default_factory=time.time) + + def to_dict(self) -> dict: + return {k: v for k, v in self.__dict__.items()} + + +# ── Cognitive Engine ─────────────────────────────────────────────────────────── + + +class CognitiveEngine: + """ + DISHA-MIND: the 7-stage cognitive loop engine. + + Usage:: + + engine = CognitiveEngine() + state = await engine.process("Analyze this threat: 192.168.1.1", session_id="abc") + print(state.action) + print(engine.get_introspection_report("abc")) + """ + + CONFIDENCE_THRESHOLD = 0.45 # below this: request clarification instead of acting + WORKING_MEMORY_DECAY = 0.92 # relevance decay applied each turn + + def __init__(self): + self.memory = MemoryManager() + self.deliberator = AgentDeliberator() + self.reasoner = HybridReasoner() + self.decision_engine = QuantumDecisionEngine() + self.goal_engine = GoalEngine() + + # Session-indexed trace store for introspection + self._traces: dict[str, list[CognitiveState]] = {} + + # ── Main entry point ─────────────────────────────────────────────────────── + + async def process(self, raw_input: str, session_id: str | None = None) -> CognitiveState: + """ + Run a full cognitive cycle on raw_input. + + Returns a fully-populated CognitiveState whose fields document + every reasoning step taken. + """ + session_id = session_id or str(uuid.uuid4()) + turn = len(self._traces.get(session_id, [])) + + state = CognitiveState( + session_id=session_id, + turn=turn, + raw_input=raw_input, + ) + + log = logger.bind(session=session_id, turn=turn) + log.info("cognitive_cycle_start", input_preview=raw_input[:80]) + + stages = [ + ("perceive", self._perceive), + ("attend", self._attend), + ("reason", self._reason), + ("deliberate", self._deliberate), + ("act", self._act), + ("reflect", self._reflect), + ("consolidate", self._consolidate), + ] + + for stage_name, stage_fn in stages: + t0 = time.perf_counter() + try: + await stage_fn(state) + except Exception as exc: + log.error("stage_failed", stage=stage_name, error=str(exc)) + state.learning_triggers.append(f"stage_error:{stage_name}") + state.stage_durations[stage_name] = round(time.perf_counter() - t0, 4) + log.debug("stage_complete", stage=stage_name, duration=state.stage_durations[stage_name]) + + self._traces.setdefault(session_id, []).append(state) + log.info("cognitive_cycle_complete", + confidence=round(state.confidence, 3), + action_type=state.action.get("type") if state.action else None, + reflection_quality=state.reflection.get("quality") if state.reflection else None) + + return state + + # ── Stage implementations ────────────────────────────────────────────────── + + async def _perceive(self, state: CognitiveState) -> None: + """ + Stage 1 — Perceive. + Extract structured meaning from raw input: intent, entities, uncertainty. + """ + text = state.raw_input.lower() + + # Intent classification (rule-based; plug in a classifier here) + intent_map = { + "investigate": ["investigate", "analyse", "analyze", "check", "scan", "look into"], + "explain": ["explain", "what is", "how does", "describe", "tell me about"], + "plan": ["plan", "strategy", "how to", "steps to", "roadmap"], + "threat": ["threat", "attack", "malware", "vulnerability", "exploit", "breach"], + "compare": ["compare", "difference", "versus", "vs", "better than"], + "summarize": ["summarize", "summary", "tldr", "overview", "brief"], + } + state.intent = "general" + for intent, keywords in intent_map.items(): + if any(k in text for k in keywords): + state.intent = intent + break + + # Entity extraction (keyword heuristics; replace with NER) + import re + ip_pattern = re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b") + domain_pattern = re.compile(r"\b[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b") + state.entities = ( + ip_pattern.findall(state.raw_input) + + [d for d in domain_pattern.findall(state.raw_input) if len(d) > 4] + )[:10] # cap at 10 + + # Uncertainty: short/vague input → high uncertainty + word_count = len(state.raw_input.split()) + state.uncertainty = max(0.1, min(0.9, 1.0 - word_count / 30)) + + async def _attend(self, state: CognitiveState) -> None: + """ + Stage 2 — Attend. + Update working memory; retrieve relevant episodic + semantic context. + """ + memory_ctx = await self.memory.retrieve(state.raw_input, state.session_id) + + state.working_memory = memory_ctx.get("working", []) + state.recalled_episodes = memory_ctx.get("episodic", [])[:4] + state.recalled_concepts = memory_ctx.get("semantic", [])[:4] + + # Decay working memory from previous turns + self.memory.working.decay(self.WORKING_MEMORY_DECAY) + + # Push current input as a new working memory item + self.memory.working.add( + {"content": state.raw_input, "intent": state.intent, "turn": state.turn}, + relevance=1.0 - state.uncertainty, + ) + + async def _reason(self, state: CognitiveState) -> None: + """ + Stage 3 — Reason. + Run deductive, inductive, and abductive reasoning in parallel; + select the most coherent hypothesis. + """ + context = { + "intent": state.intent, + "entities": state.entities, + "episodes": state.recalled_episodes, + "concepts": state.recalled_concepts, + "uncertainty": state.uncertainty, + } + + hypotheses = await self.reasoner.reason(state.raw_input, context) + state.hypotheses = [h if isinstance(h, dict) else vars(h) for h in hypotheses] + best = self.reasoner.select_best(hypotheses) + state.selected_hypothesis = best if isinstance(best, dict) else vars(best) + + async def _deliberate(self, state: CognitiveState) -> None: + """ + Stage 4 — Deliberate. + Run Planner, Executor, and Critic agents; confidence-weighted vote. + """ + result = await self.deliberator.deliberate(state) + + state.agent_deliberations = result.get("all_opinions", []) + state.consensus = result.get("winner", {}) + state.dissenting_view = result.get("dissenting_view") + + async def _act(self, state: CognitiveState) -> None: + """ + Stage 5 — Act. + Select and format the action. If confidence is too low, request + clarification instead of acting on weak reasoning. + """ + hypothesis_conf = state.selected_hypothesis.get("confidence", 0.5) if state.selected_hypothesis else 0.5 + deliberation_conf = state.consensus.get("confidence", 0.5) if state.consensus else 0.5 + state.confidence = round((hypothesis_conf + deliberation_conf) / 2, 3) + + if state.confidence < self.CONFIDENCE_THRESHOLD: + state.action = { + "type": "clarification_request", + "message": ( + f"Confidence too low ({state.confidence:.0%}) to act reliably. " + "Could you provide more detail or context?" + ), + "confidence": state.confidence, + } + else: + recommendation = (state.consensus or {}).get("recommendation", "") + state.action = { + "type": state.intent, + "response": recommendation or (state.selected_hypothesis or {}).get("hypothesis", ""), + "entities_involved": state.entities, + "confidence": state.confidence, + "reasoning_mode": (state.selected_hypothesis or {}).get("mode", "unknown"), + } + + async def _reflect(self, state: CognitiveState) -> None: + """ + Stage 6 — Reflect. + Metacognitive self-evaluation: rate the quality of this reasoning cycle. + Identify triggers that should initiate learning. + """ + quality_factors = [] + triggers = [] + + # Did agents converge? + opinions = state.agent_deliberations + if len(opinions) >= 2: + confs = [o.get("confidence", 0) for o in opinions if isinstance(o, dict)] + if confs: + variance = sum((c - sum(confs) / len(confs)) ** 2 for c in confs) / len(confs) + convergence = max(0.0, 1.0 - variance * 4) + quality_factors.append(convergence) + if convergence < 0.4: + triggers.append("agent_divergence") + + # Was confidence high enough? + quality_factors.append(state.confidence) + if state.confidence < self.CONFIDENCE_THRESHOLD: + triggers.append("low_confidence") + + # Were relevant memories retrieved? + if state.recalled_episodes or state.recalled_concepts: + quality_factors.append(0.8) + else: + quality_factors.append(0.3) + triggers.append("no_prior_context") + + # Were multiple hypotheses generated? + if len(state.hypotheses) >= 3: + quality_factors.append(0.9) + else: + quality_factors.append(0.4) + triggers.append("limited_hypothesis_diversity") + + quality = round(sum(quality_factors) / max(len(quality_factors), 1), 3) + + state.reflection = { + "quality": quality, + "factors": { + "agent_convergence": quality_factors[0] if len(quality_factors) > 0 else None, + "action_confidence": state.confidence, + "memory_retrieval": quality_factors[2] if len(quality_factors) > 2 else None, + "hypothesis_diversity": quality_factors[3] if len(quality_factors) > 3 else None, + }, + } + state.learning_triggers = triggers + + async def _consolidate(self, state: CognitiveState) -> None: + """ + Stage 7 — Consolidate. + Promote high-importance episodic memories to semantic memory. + Record the current turn as an episode for future recall. + """ + # Determine episode importance from reflection quality + confidence + importance = round( + (state.reflection.get("quality", 0.5) + state.confidence) / 2, 3 + ) if state.reflection else state.confidence + + await self.memory.store_episode( + session_id=state.session_id, + turn=state.turn, + content=state.raw_input, + outcome=str(state.action), + importance=importance, + ) + + # Consolidate high-importance episodes into semantic memory + promoted = self.memory.promote_to_semantic(state.session_id) + state.consolidated_episodes = promoted + + # Learn any concepts mentioned in recognized entities + for entity in state.entities[:3]: + if entity and len(entity) > 3: + await self.memory.learn_concept( + concept=entity, + definition=f"Entity observed in session {state.session_id}, turn {state.turn}", + relations=[{"target": state.intent, "rel_type": "related_to", "confidence": state.confidence}], + source=f"session:{state.session_id}", + ) + state.new_concepts_learned += 1 + + # ── Introspection ────────────────────────────────────────────────────────── + + def get_introspection_report(self, session_id: str) -> dict: + """Return the full reasoning trace for a session (for demo/debugging).""" + turns = self._traces.get(session_id, []) + return { + "session_id": session_id, + "total_turns": len(turns), + "turns": [t.to_dict() for t in turns], + "memory_stats": self.memory.get_memory_stats(), + "goal_tree": self.goal_engine.get_goal_tree(), + } + + def get_session_ids(self) -> list[str]: + return list(self._traces.keys()) diff --git a/cognitive-engine/intelligence/__init__.py b/cognitive-engine/intelligence/__init__.py new file mode 100644 index 00000000..dd63e3af --- /dev/null +++ b/cognitive-engine/intelligence/__init__.py @@ -0,0 +1,22 @@ +""" +cognitive-engine/intelligence — Reasoning and Decision Intelligence for DISHA. + +This subpackage contains three reasoning and decision-making components: + + HybridReasoner : Combines deductive, inductive, and abductive reasoning + to generate three hypothesis branches in parallel. + QuantumDecisionEngine: Quantum-inspired superposition model for decision selection + using amplitude scoring, interference, and collapse. + GoalEngine : Priority-queue-based goal manager with dependency tracking + and rule-based goal decomposition. +""" + +from cognitive_engine.intelligence.hybrid_reasoner import HybridReasoner +from cognitive_engine.intelligence.quantum_decision import QuantumDecisionEngine +from cognitive_engine.intelligence.goal_engine import GoalEngine + +__all__ = [ + "HybridReasoner", + "QuantumDecisionEngine", + "GoalEngine", +] diff --git a/cognitive-engine/intelligence/goal_engine.py b/cognitive-engine/intelligence/goal_engine.py new file mode 100644 index 00000000..b8ded8f1 --- /dev/null +++ b/cognitive-engine/intelligence/goal_engine.py @@ -0,0 +1,409 @@ +""" +GoalEngine — Priority-Queue Goal Manager for the DISHA Cognitive Architecture. + +Implements a heapq-based goal management system with: + - Priority ordering (1=highest, 10=lowest) + - Dependency tracking (goals unlock only when dependencies are complete) + - Rule-based goal decomposition into ordered subtasks + - Goal status lifecycle: pending → active → complete/failed + - Dependency propagation: failing a goal marks dependent goals as blocked + +Role in architecture: + GoalEngine is maintained by CognitiveEngine across sessions. New goals are + added via the POST /cognitive/goal API endpoint. During each cognitive turn, + get_next_actionable() provides the highest-priority actionable goal to the + _deliberate phase. Completed goals unlock dependent goals automatically. +""" + +from __future__ import annotations + +import heapq +import time +import uuid +from typing import Any + +import structlog + +log = structlog.get_logger(__name__) + +# Goal status constants +STATUS_PENDING = "pending" +STATUS_ACTIVE = "active" +STATUS_COMPLETE = "complete" +STATUS_FAILED = "failed" +STATUS_BLOCKED = "blocked" + +# Rule-based decomposition templates (intent keyword → subtask list) +_DECOMPOSITION_TEMPLATES: dict[str, list[str]] = { + "research": [ + "define_scope_and_search_terms", + "retrieve_relevant_memories", + "synthesize_information", + "validate_findings", + "report_results", + ], + "build": [ + "specify_requirements", + "design_architecture", + "implement_components", + "test_and_validate", + "deploy_and_monitor", + ], + "analyze": [ + "gather_data", + "preprocess_data", + "apply_analytical_framework", + "interpret_results", + "formulate_conclusions", + ], + "communicate": [ + "understand_audience", + "draft_message", + "review_tone_and_clarity", + "deliver_message", + "confirm_receipt", + ], + "solve": [ + "identify_root_cause", + "generate_candidate_solutions", + "evaluate_solutions", + "implement_best_solution", + "verify_resolution", + ], + "plan": [ + "clarify_objectives", + "identify_constraints", + "enumerate_options", + "select_optimal_path", + "create_execution_schedule", + ], + "learn": [ + "identify_knowledge_gap", + "gather_learning_materials", + "process_and_understand", + "test_comprehension", + "integrate_into_memory", + ], + "default": [ + "clarify_goal", + "gather_context", + "formulate_approach", + "execute_approach", + "evaluate_outcome", + ], +} + + +class _GoalEntry: + """ + Heap-compatible wrapper for a goal dict. + + heapq is a min-heap; since priority 1 = highest importance, we negate + priority for the heap ordering and use timestamp as tiebreaker. + """ + + __slots__ = ("priority", "timestamp", "goal") + + def __init__(self, goal: dict[str, Any]) -> None: + self.priority = -goal["priority"] # negate for min-heap → highest priority first + self.timestamp = goal["created_at"] + self.goal = goal + + def __lt__(self, other: "_GoalEntry") -> bool: + if self.priority != other.priority: + return self.priority < other.priority # lower neg = higher priority + return self.timestamp < other.timestamp # FIFO tiebreaker + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _GoalEntry): + return False + return self.goal["goal_id"] == other.goal["goal_id"] + + +class GoalEngine: + """ + Priority-queue goal manager with dependency resolution and subtask decomposition. + """ + + def __init__(self) -> None: + self._heap: list[_GoalEntry] = [] + self._goals: dict[str, dict[str, Any]] = {} # goal_id → goal dict (fast lookup) + log.info("goal_engine.initialized") + + # ------------------------------------------------------------------ + # Goal creation + # ------------------------------------------------------------------ + + def add_goal( + self, + description: str, + priority: int = 5, + dependencies: list[str] | None = None, + ) -> str: + """ + Add a new goal to the priority queue. + + Args: + description: Human-readable goal description. + priority: Integer 1 (highest) to 10 (lowest). Clamped to [1, 10]. + dependencies: List of goal_ids that must be complete before this goal + becomes actionable. Defaults to []. + + Returns: + The generated goal_id (UUID string). + """ + goal_id = str(uuid.uuid4()) + priority = max(1, min(10, priority)) + deps = dependencies or [] + + goal: dict[str, Any] = { + "goal_id": goal_id, + "description": description, + "priority": priority, + "dependencies": deps, + "status": STATUS_PENDING, + "subtasks": [], + "outcome": None, + "failure_reason": None, + "created_at": time.time(), + "updated_at": time.time(), + } + + self._goals[goal_id] = goal + heapq.heappush(self._heap, _GoalEntry(goal)) + + log.info( + "goal_engine.goal_added", + goal_id=goal_id, + priority=priority, + dependencies=deps, + description=description[:80], + ) + return goal_id + + # ------------------------------------------------------------------ + # Goal decomposition + # ------------------------------------------------------------------ + + def decompose(self, goal_id: str) -> list[dict[str, Any]]: + """ + Break a goal into ordered subtasks using rule-based decomposition. + + The decomposition template is selected by matching keywords from the + goal description against template keys. Each subtask gets a unique ID, + an order index, and a 'pending' status. + + Args: + goal_id: ID of the goal to decompose. + + Returns: + List of subtask dicts, or empty list if goal not found. + """ + goal = self._goals.get(goal_id) + if not goal: + log.warning("goal_engine.decompose_not_found", goal_id=goal_id) + return [] + + description = goal["description"].lower() + + # Select best template based on keyword overlap + best_template = "default" + best_score = 0 + for keyword, _ in _DECOMPOSITION_TEMPLATES.items(): + if keyword in description: + score = len(keyword) + if score > best_score: + best_score = score + best_template = keyword + + template_steps = _DECOMPOSITION_TEMPLATES[best_template] + + subtasks: list[dict[str, Any]] = [ + { + "subtask_id": str(uuid.uuid4()), + "goal_id": goal_id, + "order": i, + "description": step.replace("_", " "), + "status": STATUS_PENDING, + "created_at": time.time(), + } + for i, step in enumerate(template_steps) + ] + + goal["subtasks"] = subtasks + goal["updated_at"] = time.time() + + log.info( + "goal_engine.goal_decomposed", + goal_id=goal_id, + template=best_template, + subtasks=len(subtasks), + ) + return subtasks + + # ------------------------------------------------------------------ + # Goal retrieval + # ------------------------------------------------------------------ + + def get_next_actionable(self) -> dict[str, Any] | None: + """ + Return the highest-priority goal whose dependencies are all complete. + + Scans the heap from highest to lowest priority. Goals with unmet + dependencies are skipped. Returns None if no goal is actionable. + + Returns: + Goal dict or None. + """ + # Rebuild heap entries for clean iteration (heap may have stale entries) + pending_goals = sorted( + [g for g in self._goals.values() if g["status"] in (STATUS_PENDING, STATUS_ACTIVE)], + key=lambda g: (-g["priority"], g["created_at"]), + ) + + for goal in pending_goals: + deps_met = all( + self._goals.get(dep_id, {}).get("status") == STATUS_COMPLETE + for dep_id in goal["dependencies"] + ) + if deps_met: + if goal["status"] == STATUS_PENDING: + goal["status"] = STATUS_ACTIVE + goal["updated_at"] = time.time() + log.info( + "goal_engine.goal_activated", + goal_id=goal["goal_id"], + priority=goal["priority"], + ) + return goal + + log.debug("goal_engine.no_actionable_goal", total_goals=len(self._goals)) + return None + + # ------------------------------------------------------------------ + # Goal completion / failure + # ------------------------------------------------------------------ + + def mark_complete(self, goal_id: str, outcome: dict[str, Any]) -> None: + """ + Mark a goal as complete and unlock dependent goals. + + Args: + goal_id: ID of the goal to mark complete. + outcome: Dict describing the outcome (free-form). + """ + goal = self._goals.get(goal_id) + if not goal: + log.warning("goal_engine.complete_not_found", goal_id=goal_id) + return + + goal["status"] = STATUS_COMPLETE + goal["outcome"] = outcome + goal["updated_at"] = time.time() + + # Unlock any goals that depended on this one + unlocked = [ + g for g in self._goals.values() + if goal_id in g.get("dependencies", []) and g["status"] == STATUS_BLOCKED + ] + for dependent in unlocked: + dependent["status"] = STATUS_PENDING + dependent["updated_at"] = time.time() + + log.info( + "goal_engine.goal_completed", + goal_id=goal_id, + unlocked=len(unlocked), + ) + + def mark_failed(self, goal_id: str, reason: str) -> None: + """ + Mark a goal as failed and propagate failure to all dependent goals. + + Args: + goal_id: ID of the failed goal. + reason: Human-readable failure reason. + """ + goal = self._goals.get(goal_id) + if not goal: + log.warning("goal_engine.failed_not_found", goal_id=goal_id) + return + + goal["status"] = STATUS_FAILED + goal["failure_reason"] = reason + goal["updated_at"] = time.time() + + # Propagate failure: all goals that depend on this one are blocked + blocked = [ + g for g in self._goals.values() + if goal_id in g.get("dependencies", []) + and g["status"] in (STATUS_PENDING, STATUS_ACTIVE) + ] + for dependent in blocked: + dependent["status"] = STATUS_BLOCKED + dependent["failure_reason"] = f"Blocked by failed dependency: {goal_id}" + dependent["updated_at"] = time.time() + + log.warning( + "goal_engine.goal_failed", + goal_id=goal_id, + reason=reason, + blocked_dependents=len(blocked), + ) + + # ------------------------------------------------------------------ + # Goal tree visualization + # ------------------------------------------------------------------ + + def get_goal_tree(self) -> dict[str, Any]: + """ + Return the full goal graph as a nested dict suitable for visualization. + + Each top-level entry is a goal with no dependencies (root node). + Its dependents are nested recursively under 'dependents'. + + Returns: + {root_goal_id: {goal_data, dependents: {...}}} + """ + + def _build_subtree(goal_id: str, visited: set[str]) -> dict[str, Any]: + if goal_id in visited: + return {"circular_reference": goal_id} + visited = visited | {goal_id} + goal = self._goals.get(goal_id, {}) + dependents = { + g["goal_id"]: _build_subtree(g["goal_id"], visited) + for g in self._goals.values() + if goal_id in g.get("dependencies", []) + } + return {**goal, "dependents": dependents} + + # Find root goals (no dependencies or all deps are external) + root_ids = [ + gid for gid, g in self._goals.items() + if not g.get("dependencies") or all( + dep not in self._goals for dep in g["dependencies"] + ) + ] + + tree = {gid: _build_subtree(gid, set()) for gid in root_ids} + log.debug("goal_engine.tree_generated", roots=len(root_ids), total=len(self._goals)) + return tree + + def get_all_goals(self) -> list[dict[str, Any]]: + """Return all goals as a flat list sorted by priority descending.""" + return sorted( + list(self._goals.values()), + key=lambda g: (-g["priority"], g["created_at"]), + ) + + def stats(self) -> dict[str, Any]: + """Return summary statistics about the goal queue.""" + statuses: dict[str, int] = {} + for g in self._goals.values(): + statuses[g["status"]] = statuses.get(g["status"], 0) + 1 + return { + "total_goals": len(self._goals), + "by_status": statuses, + "heap_size": len(self._heap), + } diff --git a/cognitive-engine/intelligence/hybrid_reasoner.py b/cognitive-engine/intelligence/hybrid_reasoner.py new file mode 100644 index 00000000..9f855d4a --- /dev/null +++ b/cognitive-engine/intelligence/hybrid_reasoner.py @@ -0,0 +1,368 @@ +""" +HybridReasoner — Three-Mode Reasoning Engine for the DISHA Cognitive Architecture. + +Combines three classical reasoning paradigms in parallel: + + Deductive — Apply if-then rules derived from known facts to reach a certain conclusion. + Inductive — Identify patterns across context evidence to generalize a hypothesis. + Abductive — Find the simplest, most parsimonious explanation for all observed evidence. + +Each mode produces one hypothesis dict. The three are compared for coherence and +the best is selected by the select_best() method. + +Role in architecture: + HybridReasoner is called during the _reason phase of the cognitive loop. + Its output (list of 3 hypotheses) is stored in CognitiveState.hypotheses + and consumed by both AgentDeliberator and the _reflect phase. +""" + +from __future__ import annotations + +import asyncio +import re +import time +from typing import Any + +import structlog + +log = structlog.get_logger(__name__) + +# --------------------------------------------------------------------------- +# Heuristic rule base for deductive reasoning +# --------------------------------------------------------------------------- + +# Each rule: (condition_keywords, conclusion_template) +_DEDUCTIVE_RULES: list[tuple[list[str], str]] = [ + (["what", "is", "define", "explain", "describe"], "This is an information-retrieval request requiring a factual definition or explanation."), + (["how", "do", "can", "should", "implement", "create", "build"], "This is a procedural request requiring step-by-step guidance."), + (["why", "reason", "cause", "because"], "This is a causal-analysis request requiring explanation of underlying mechanisms."), + (["compare", "difference", "versus", "vs", "better"], "This is a comparative request requiring evaluation across multiple options."), + (["help", "assist", "support", "need"], "This is an assistance request requiring empathetic, action-oriented support."), + (["error", "bug", "fix", "problem", "issue", "fail"], "This is a debugging request requiring systematic root-cause analysis."), + (["predict", "forecast", "future", "will", "expect"], "This is a predictive request requiring probabilistic reasoning about future states."), + (["sentiment", "feel", "emotion", "mood", "opinion"], "This is a sentiment/opinion request requiring affective understanding."), +] + +# --------------------------------------------------------------------------- +# Pattern heuristics for inductive reasoning +# --------------------------------------------------------------------------- + +_INDUCTIVE_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"\b(always|never|every|all|none)\b", re.I), "universal_quantification"), + (re.compile(r"\b(sometimes|often|usually|frequently|rarely)\b", re.I), "probabilistic_claim"), + (re.compile(r"\b(increase|decrease|grow|shrink|trend|over time)\b", re.I), "temporal_trend"), + (re.compile(r"\b(similar|like|same|parallel|analogous)\b", re.I), "analogy_pattern"), + (re.compile(r"\b(if|when|unless|provided|given that)\b", re.I), "conditional_pattern"), + (re.compile(r"\b(example|instance|case|such as|e\.g\.)\b", re.I), "exemplification_pattern"), + (re.compile(r"\d+", re.I), "quantitative_data_present"), + (re.compile(r"\b(first|second|third|finally|then|next|lastly)\b", re.I), "sequential_structure"), +] + +# --------------------------------------------------------------------------- +# HybridReasoner +# --------------------------------------------------------------------------- + + +class HybridReasoner: + """ + Parallel three-mode reasoner producing deductive, inductive, and abductive hypotheses. + + TODO: Replace heuristic methods with LLM-backed reasoning chains: + async def _deductive(self, query, context): + prompt = f"Apply formal deductive logic to: '{query}'. Known facts: {context}" + llm_output = await call_llm(prompt) + return parse_hypothesis(llm_output, mode="deductive") + """ + + def __init__(self) -> None: + log.info("hybrid_reasoner.initialized") + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + async def reason(self, query: str, context: dict[str, Any]) -> list[dict[str, Any]]: + """ + Generate three hypotheses in parallel — one per reasoning mode. + + Args: + query: The raw input or distilled question to reason about. + context: Enriched context dict from the _perceive/_attend phases. + + Returns: + List of 3 hypothesis dicts: + [ + {mode, hypothesis, confidence, evidence, chain_of_thought}, + ... + ] + """ + log.info("hybrid_reasoner.reasoning", query=query[:80]) + + # Run all three reasoning modes concurrently + deductive_h, inductive_h, abductive_h = await asyncio.gather( + asyncio.to_thread(self._deductive, query, context), + asyncio.to_thread(self._inductive, query, context), + asyncio.to_thread(self._abductive, query, context), + ) + + hypotheses = [deductive_h, inductive_h, abductive_h] + log.info( + "hybrid_reasoner.hypotheses_generated", + deductive_conf=deductive_h["confidence"], + inductive_conf=inductive_h["confidence"], + abductive_conf=abductive_h["confidence"], + ) + return hypotheses + + def select_best(self, hypotheses: list[dict[str, Any]]) -> dict[str, Any]: + """ + Select the hypothesis with the highest confidence, applying a coherence check. + + Coherence check: if the top-2 hypotheses are within 0.05 of each other, + prefer the abductive one (Occam's razor — prefer the simpler explanation). + + Args: + hypotheses: List of hypothesis dicts (from reason()). + + Returns: + The selected best hypothesis dict. + """ + if not hypotheses: + return { + "mode": "none", + "hypothesis": "Insufficient data to form a hypothesis.", + "confidence": 0.0, + "evidence": [], + "chain_of_thought": [], + } + + sorted_h = sorted(hypotheses, key=lambda h: h.get("confidence", 0.0), reverse=True) + best = sorted_h[0] + + # Coherence check: prefer abductive when top-2 are close + if len(sorted_h) > 1: + gap = best["confidence"] - sorted_h[1]["confidence"] + if gap < 0.05: + # Tiebreak: prefer abductive (simplest explanation) + abductive_options = [h for h in sorted_h if h.get("mode") == "abductive"] + if abductive_options: + best = abductive_options[0] + log.debug("hybrid_reasoner.coherence_tiebreak", selected="abductive") + + log.debug( + "hybrid_reasoner.best_selected", + mode=best["mode"], + confidence=best["confidence"], + ) + return best + + # ------------------------------------------------------------------ + # Reasoning mode implementations + # ------------------------------------------------------------------ + + def _deductive(self, query: str, context: dict[str, Any]) -> dict[str, Any]: + """ + Deductive reasoning: match query against known if-then rules to derive a conclusion. + + Logic: + 1. Tokenize the query into lowercase words. + 2. For each rule, count how many condition keywords match. + 3. Select the rule with the most matches as the deductive premise. + 4. Derive conclusion from the premise. + + TODO: Integrate formal logic engine or LLM for real deductive proofs. + """ + query_lower = query.lower() + query_tokens = set(query_lower.split()) + + best_match_count = 0 + best_conclusion = "No applicable deductive rule found; conclusion uncertain." + best_rule_keywords: list[str] = [] + + for keywords, conclusion in _DEDUCTIVE_RULES: + match_count = sum(1 for k in keywords if k in query_tokens or k in query_lower) + if match_count > best_match_count: + best_match_count = match_count + best_conclusion = conclusion + best_rule_keywords = keywords + + # Confidence is proportional to how many rule keywords matched + max_possible = max(len(kws) for kws, _ in _DEDUCTIVE_RULES) + rule_coverage = best_match_count / max(max_possible, 1) + confidence = 0.45 + (rule_coverage * 0.45) + + # Boost confidence if context contains supporting episodic/semantic data + if context.get("episodic_memories"): + confidence = min(confidence + 0.05, 0.99) + if context.get("semantic_facts"): + confidence = min(confidence + 0.05, 0.99) + + # Build chain of thought + chain: list[str] = [ + f"Query: '{query[:80]}'", + f"Matched rule keywords: {best_rule_keywords or ['(none)']}", + f"Rule coverage: {rule_coverage:.2%}", + f"Applied deductive rule → {best_conclusion}", + ] + + # Collect evidence from context + evidence: list[str] = [] + for ep in context.get("episodic_memories", [])[:2]: + evidence.append(f"Past episode: {ep.get('what', '')[:80]}") + for sf in context.get("semantic_facts", [])[:2]: + evidence.append(f"Known fact: {sf.get('definition', '')[:80]}") + + return { + "mode": "deductive", + "hypothesis": best_conclusion, + "confidence": round(confidence, 3), + "evidence": evidence, + "chain_of_thought": chain, + "matched_keywords": best_rule_keywords, + "timestamp": time.time(), + } + + def _inductive(self, query: str, context: dict[str, Any]) -> dict[str, Any]: + """ + Inductive reasoning: detect patterns in the query and context to form a generalization. + + Logic: + 1. Scan query and context for pattern markers (regex-based). + 2. For each matched pattern type, build a partial generalization. + 3. Synthesize all matched patterns into a single inductive hypothesis. + + TODO: Replace with LLM few-shot inductive reasoning chains. + """ + matched_patterns: list[str] = [] + for pattern, label in _INDUCTIVE_PATTERNS: + if pattern.search(query): + matched_patterns.append(label) + + # Check for patterns in context working memory items + context_text = " ".join( + str(item.get("content", "")) for item in context.get("working_memory", []) + ) + for pattern, label in _INDUCTIVE_PATTERNS: + if pattern.search(context_text) and label not in matched_patterns: + matched_patterns.append(f"context:{label}") + + # Build inductive hypothesis from matched patterns + if not matched_patterns: + hypothesis = ( + "No strong patterns detected; the input appears to be an isolated, " + "novel statement requiring further context to generalize." + ) + confidence = 0.35 + else: + pattern_descriptions: list[str] = [] + for p in matched_patterns: + clean = p.replace("context:", "").replace("_", " ") + pattern_descriptions.append(clean) + + hypothesis = ( + f"Pattern analysis identifies {len(matched_patterns)} structural signals " + f"({', '.join(pattern_descriptions[:3])}). " + f"Generalizing: this input follows a {pattern_descriptions[0]} structure, " + f"suggesting the response should address this recurring pattern type." + ) + confidence = 0.40 + min(len(matched_patterns) * 0.08, 0.45) + + chain: list[str] = [ + f"Query scanned for {len(_INDUCTIVE_PATTERNS)} pattern types", + f"Matched patterns: {matched_patterns or ['none']}", + f"Generalization formed from {len(matched_patterns)} pattern signals", + f"Inductive conclusion: {hypothesis[:100]}...", + ] + + evidence = [f"Pattern signal: {p}" for p in matched_patterns[:5]] + + return { + "mode": "inductive", + "hypothesis": hypothesis, + "confidence": round(confidence, 3), + "evidence": evidence, + "chain_of_thought": chain, + "matched_patterns": matched_patterns, + "timestamp": time.time(), + } + + def _abductive(self, query: str, context: dict[str, Any]) -> dict[str, Any]: + """ + Abductive reasoning: find the simplest explanation consistent with all evidence. + + Logic: + 1. Collect all evidence fragments (query tokens, context entities, memories). + 2. Identify the most prominent theme (highest-frequency content word). + 3. Propose the minimal explanation that accounts for all evidence. + + Based on Peirce's abduction: "Choose the hypothesis that would make + the observations most likely / most natural." + + TODO: Replace with LLM chain-of-thought abductive reasoning. + """ + # Gather all evidence + query_words = [w.strip(".,!?:;\"'") for w in query.lower().split() if len(w) > 3] + + entity_words: list[str] = [] + for ent in context.get("entities", []): + if isinstance(ent, dict): + entity_words.append(str(ent.get("value", ent.get("text", ""))).lower()) + else: + entity_words.append(str(ent).lower()) + + memory_words: list[str] = [] + for ep in context.get("episodic_memories", [])[:3]: + memory_words += ep.get("what", "").lower().split()[:5] + + all_evidence = query_words + entity_words + memory_words + + # Word frequency analysis — find the dominant theme + freq: dict[str, int] = {} + for w in all_evidence: + if len(w) > 3: + freq[w] = freq.get(w, 0) + 1 + + # Sort by frequency + dominant_words = sorted(freq.items(), key=lambda x: x[1], reverse=True)[:5] + + if not dominant_words: + hypothesis = ( + "The simplest explanation is that this is a novel input with no clear " + "prior context. The best action is to treat it as a fresh information request." + ) + confidence = 0.40 + else: + top_word, top_freq = dominant_words[0] + all_top = [w for w, _ in dominant_words] + hypothesis = ( + f"The simplest explanation unifying all evidence: this input is primarily " + f"about '{top_word}' (appears {top_freq}× across query and context). " + f"Related themes ({', '.join(all_top[1:4])}) support this reading. " + f"The most parsimonious response addresses '{top_word}' directly." + ) + # Confidence scales with evidence density and entity presence + confidence = 0.50 + min(len(all_evidence) / 40.0, 0.30) + if entity_words: + confidence = min(confidence + 0.08, 0.92) + + chain: list[str] = [ + f"Evidence collected: {len(query_words)} query words, " + f"{len(entity_words)} entities, {len(memory_words)} memory fragments", + f"Dominant themes: {[w for w, _ in dominant_words[:3]] or ['none']}", + f"Occam's razor applied: selecting minimal hypothesis", + f"Abductive conclusion: {hypothesis[:100]}...", + ] + + evidence = [ + f"Dominant term: '{w}' (freq={f})" for w, f in dominant_words[:5] + ] + [f"Entity: {e}" for e in entity_words[:3]] + + return { + "mode": "abductive", + "hypothesis": hypothesis, + "confidence": round(confidence, 3), + "evidence": evidence, + "chain_of_thought": chain, + "dominant_themes": [w for w, _ in dominant_words], + "timestamp": time.time(), + } diff --git a/cognitive-engine/intelligence/quantum_decision.py b/cognitive-engine/intelligence/quantum_decision.py new file mode 100644 index 00000000..d7c08e45 --- /dev/null +++ b/cognitive-engine/intelligence/quantum_decision.py @@ -0,0 +1,422 @@ +""" +QuantumDecisionEngine — Quantum-Inspired Decision Framework for DISHA. + +IMPORTANT DISCLAIMER: This module is quantum-INSPIRED, not actual quantum computing. +It borrows the mathematical metaphors of quantum mechanics — superposition, amplitude, +interference, entanglement, collapse — to model multi-option decision-making under +uncertainty. No quantum hardware or libraries are used. + +Core concepts modelled: + Superposition : Multiple candidate decisions exist simultaneously as weighted possibilities. + Amplitude : Each option's "quantum amplitude" represents its likelihood/fitness score. + Interference : Conflicting options destructively reduce each other's amplitudes. + Constructive : Reinforcing options constructively boost each other's amplitudes. + Collapse : Applying a constraint collapses the superposition to a single best option. + Entanglement : Shows how choosing decision A correlates with decision B's outcome space. + +Role in architecture: + QuantumDecisionEngine is available to the _act phase as an optional + decision optimization layer. It is particularly useful when the AgentDeliberator + produces multiple high-confidence options that are difficult to discriminate. +""" + +from __future__ import annotations + +import asyncio +import math +import time +from typing import Any + +import structlog + +log = structlog.get_logger(__name__) + +# Context-keyword score table: if a keyword from the option appears in context, +# it gets a positive amplitude boost. +_CONTEXT_BOOST_WEIGHT = 0.15 +_CONFLICT_PENALTY = 0.20 +_REINFORCEMENT_BOOST = 0.12 + +# Known conflicting option pairs (simplified heuristic) +_CONFLICT_PAIRS: list[tuple[str, str]] = [ + ("approve", "reject"), + ("proceed", "abort"), + ("expand", "restrict"), + ("disclose", "conceal"), + ("accept", "decline"), + ("increase", "decrease"), + ("simplify", "elaborate"), +] + +# Known reinforcing pairs +_REINFORCE_PAIRS: list[tuple[str, str]] = [ + ("explain", "clarify"), + ("analyze", "evaluate"), + ("plan", "execute"), + ("confirm", "validate"), + ("search", "retrieve"), +] + + +class QuantumDecisionEngine: + """ + Quantum-inspired decision optimizer using superposition, interference, and collapse. + + The engine maintains no persistent state between calls; each method is stateless + and operates on the provided superposition list. + """ + + def __init__(self) -> None: + log.info("quantum_decision_engine.initialized") + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + async def superpose( + self, options: list[str], context: dict[str, Any] + ) -> list[dict[str, Any]]: + """ + Place all options into superposition by computing initial amplitudes. + + Each option gets an amplitude score in [0, 1] based on context + keyword matching and heuristics. Then interference is applied to + reduce amplitudes of conflicting options. + + Args: + options: List of candidate decision strings. + context: Current cognitive context dict. + + Returns: + List of superposition entries: + [{option, amplitude, interference_factor, raw_amplitude}] + """ + if not options: + return [] + + log.info( + "quantum_decision.superpose", + num_options=len(options), + context_keys=list(context.keys()), + ) + + # Compute raw amplitudes in parallel (thread pool since it's CPU-bound heuristics) + tasks = [ + asyncio.to_thread(self._compute_amplitude, opt, context) for opt in options + ] + raw_amplitudes = await asyncio.gather(*tasks) + + superposition: list[dict[str, Any]] = [ + { + "option": opt, + "amplitude": amp, + "raw_amplitude": amp, + "interference_factor": 1.0, + } + for opt, amp in zip(options, raw_amplitudes) + ] + + # Apply destructive interference + superposition = self._apply_interference(superposition) + + # Apply constructive interference (boost) + superposition = self._apply_constructive(superposition) + + # Normalize amplitudes to [0, 1] + max_amp = max((s["amplitude"] for s in superposition), default=1.0) + if max_amp > 0: + for s in superposition: + s["amplitude"] = round(s["amplitude"] / max_amp, 4) + + # Sort by amplitude descending + superposition.sort(key=lambda s: s["amplitude"], reverse=True) + + log.debug( + "quantum_decision.superposition_created", + options=[(s["option"][:30], s["amplitude"]) for s in superposition], + ) + return superposition + + async def collapse( + self, superposition: list[dict[str, Any]], constraint: str + ) -> dict[str, Any]: + """ + Collapse the superposition to a single decision by applying a constraint. + + The constraint is matched against each option as a keyword filter. + Options that contain constraint keywords get an amplitude boost before + the final collapse measurement. + + Args: + superposition: Output of superpose(). + constraint: Natural language constraint string (e.g. "must be safe"). + + Returns: + The collapsed (selected) option dict with {option, final_amplitude, reason}. + """ + if not superposition: + return {"option": "no_decision", "final_amplitude": 0.0, "reason": "empty_superposition"} + + log.info("quantum_decision.collapse", constraint=constraint[:60]) + + constraint_words = set(constraint.lower().split()) + + # Apply constraint boost + boosted: list[dict[str, Any]] = [] + for entry in superposition: + option_words = set(entry["option"].lower().split()) + overlap = len(constraint_words & option_words) / max(len(constraint_words), 1) + boosted_amplitude = entry["amplitude"] * (1.0 + overlap * 0.5) + boosted.append( + { + **entry, + "amplitude": min(boosted_amplitude, 1.0), + "constraint_overlap": round(overlap, 3), + } + ) + + # Measurement: select highest amplitude (wave function collapse) + selected = max(boosted, key=lambda s: s["amplitude"]) + + result = { + "option": selected["option"], + "final_amplitude": round(selected["amplitude"], 4), + "constraint_overlap": selected.get("constraint_overlap", 0.0), + "reason": ( + f"Highest post-constraint amplitude ({selected['amplitude']:.4f}) " + f"after applying constraint '{constraint[:40]}'" + ), + "all_amplitudes": [ + {"option": s["option"][:40], "amplitude": round(s["amplitude"], 4)} + for s in sorted(boosted, key=lambda x: x["amplitude"], reverse=True) + ], + "timestamp": time.time(), + } + + log.info( + "quantum_decision.collapsed", + selected=selected["option"][:60], + amplitude=selected["amplitude"], + ) + return result + + async def entangle( + self, decision_a: dict[str, Any], decision_b: dict[str, Any] + ) -> dict[str, Any]: + """ + Analyse how decision A correlates with the outcome space of decision B. + + 'Entanglement' here means: if A is chosen, what does it imply about + which options in B become more or less likely? This is modelled as + semantic keyword correlation between the two decisions' option spaces. + + Args: + decision_a: A superposition dict or collapsed decision dict. + decision_b: A superposition dict or collapsed decision dict. + + Returns: + { + "a_chosen": str, + "b_implications": list[{option, correlation, direction}], + "entanglement_strength": float, + "explanation": str, + } + """ + log.info("quantum_decision.entangle") + + # Extract option strings from either format + a_option: str = decision_a.get("option", str(decision_a)) + b_options: list[str] = [] + if "all_amplitudes" in decision_b: + b_options = [x["option"] for x in decision_b["all_amplitudes"]] + elif "option" in decision_b: + b_options = [decision_b["option"]] + else: + b_options = [str(decision_b)] + + a_words = set(a_option.lower().split()) + + # For each B option, compute semantic correlation with A + implications: list[dict[str, Any]] = [] + for b_opt in b_options[:8]: # limit to 8 options max + b_words = set(b_opt.lower().split()) + + # Semantic overlap + overlap = len(a_words & b_words) / max(len(a_words | b_words), 1) + + # Check for known conflict or reinforcement + direction = "neutral" + for ca, cb in _CONFLICT_PAIRS: + if (ca in a_option.lower() and cb in b_opt.lower()) or ( + cb in a_option.lower() and ca in b_opt.lower() + ): + direction = "conflicting" + break + if direction == "neutral": + for ra, rb in _REINFORCE_PAIRS: + if (ra in a_option.lower() and rb in b_opt.lower()) or ( + rb in a_option.lower() and ra in b_opt.lower() + ): + direction = "reinforcing" + break + + implications.append( + { + "option": b_opt, + "correlation": round(overlap + (0.2 if direction == "reinforcing" else -0.1 if direction == "conflicting" else 0.0), 4), + "direction": direction, + } + ) + + implications.sort(key=lambda x: x["correlation"], reverse=True) + + entanglement_strength = ( + sum(abs(imp["correlation"]) for imp in implications) / max(len(implications), 1) + ) + + explanation = ( + f"Choosing '{a_option[:40]}' creates correlation structure across B's option space. " + f"Strongest co-selection: '{implications[0]['option'][:30] if implications else 'N/A'}' " + f"({implications[0]['direction'] if implications else 'N/A'}). " + f"Overall entanglement strength: {entanglement_strength:.3f}." + ) + + return { + "a_chosen": a_option, + "b_implications": implications, + "entanglement_strength": round(entanglement_strength, 4), + "explanation": explanation, + "timestamp": time.time(), + } + + # ------------------------------------------------------------------ + # Internal quantum-inspired computations + # ------------------------------------------------------------------ + + def _compute_amplitude(self, option: str, context: dict[str, Any]) -> float: + """ + Score an option against context using keyword matching and heuristics. + + Amplitude formula: + base = 0.5 (equal prior) + + context_keyword_boost (max +0.30) + + intent_alignment (max +0.15) + + recency_of_similar_episodes (max +0.10) + + Args: + option: The decision option string. + context: Cognitive context dict. + + Returns: + Raw amplitude score in [0.1, 1.0]. + """ + option_words = set(option.lower().split()) + amplitude = 0.5 # uniform prior (equal superposition) + + # Context keyword match + context_text = str(context).lower() + context_words = set(context_text.split()) + overlap = len(option_words & context_words) / max(len(option_words), 1) + amplitude += overlap * _CONTEXT_BOOST_WEIGHT * 2 + + # Intent alignment bonus + intent = str(context.get("intent", "")).lower() + if any(w in intent for w in option_words): + amplitude += 0.10 + + # Episodic memory: if similar action led to good outcome before, boost + for ep in context.get("episodic_memories", [])[:5]: + ep_text = str(ep.get("what", "") + " " + ep.get("outcome", "")).lower() + ep_words = set(ep_text.split()) + ep_overlap = len(option_words & ep_words) / max(len(option_words), 1) + if ep_overlap > 0.2 and ep.get("importance", 0.5) > 0.6: + amplitude += ep_overlap * 0.10 + + # Semantic memory alignment + for sf in context.get("semantic_facts", [])[:3]: + sf_text = str(sf.get("definition", "")).lower() + sf_words = set(sf_text.split()) + sf_overlap = len(option_words & sf_words) / max(len(option_words), 1) + amplitude += sf_overlap * 0.05 + + # Apply sigmoid-like squashing to keep in [0.1, 0.95] + amplitude = 0.1 + 0.85 * (1 / (1 + math.exp(-6 * (amplitude - 0.5)))) + + return round(amplitude, 4) + + def _apply_interference( + self, superposition: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """ + Destructive interference: reduce amplitude of options that conflict with others. + + For each known conflict pair, if both options are present in the superposition, + the lower-amplitude one receives a penalty. + + Args: + superposition: Current superposition list. + + Returns: + Updated superposition with destructive interference applied. + """ + option_map = {s["option"].lower(): i for i, s in enumerate(superposition)} + + for ca, cb in _CONFLICT_PAIRS: + # Find entries containing these conflict keywords + a_indices = [i for opt, i in option_map.items() if ca in opt] + b_indices = [i for opt, i in option_map.items() if cb in opt] + + for ai in a_indices: + for bi in b_indices: + amp_a = superposition[ai]["amplitude"] + amp_b = superposition[bi]["amplitude"] + # Lower amplitude option gets destructive penalty + if amp_a < amp_b: + penalty = _CONFLICT_PENALTY * amp_a + superposition[ai]["amplitude"] = max(0.01, amp_a - penalty) + superposition[ai]["interference_factor"] = round( + superposition[ai]["interference_factor"] * (1 - _CONFLICT_PENALTY), 4 + ) + else: + penalty = _CONFLICT_PENALTY * amp_b + superposition[bi]["amplitude"] = max(0.01, amp_b - penalty) + superposition[bi]["interference_factor"] = round( + superposition[bi]["interference_factor"] * (1 - _CONFLICT_PENALTY), 4 + ) + + log.debug("quantum_decision.destructive_interference_applied") + return superposition + + def _apply_constructive( + self, superposition: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """ + Constructive interference: boost amplitudes of options that reinforce each other. + + For each known reinforcement pair, if both options are present, both receive + a proportional amplitude boost. + + Args: + superposition: Current superposition list. + + Returns: + Updated superposition with constructive interference applied. + """ + for ra, rb in _REINFORCE_PAIRS: + a_indices = [ + i for i, s in enumerate(superposition) if ra in s["option"].lower() + ] + b_indices = [ + i for i, s in enumerate(superposition) if rb in s["option"].lower() + ] + + for ai in a_indices: + for bi in b_indices: + # Both get a boost + boost_a = _REINFORCEMENT_BOOST * superposition[ai]["amplitude"] + boost_b = _REINFORCEMENT_BOOST * superposition[bi]["amplitude"] + superposition[ai]["amplitude"] = min(1.0, superposition[ai]["amplitude"] + boost_a) + superposition[bi]["amplitude"] = min(1.0, superposition[bi]["amplitude"] + boost_b) + + log.debug("quantum_decision.constructive_interference_applied") + return superposition diff --git a/cognitive-engine/memory/__init__.py b/cognitive-engine/memory/__init__.py new file mode 100644 index 00000000..b527fd55 --- /dev/null +++ b/cognitive-engine/memory/__init__.py @@ -0,0 +1,21 @@ +""" +cognitive-engine/memory — Memory subsystem for the DISHA Cognitive Architecture. + +Exports three complementary memory types: + - WorkingMemory : 8-slot attention buffer (short-term, volatile) + - EpisodicMemory : Time-stamped event store (medium-term, autobiographical) + - SemanticMemory : Concept graph (long-term, factual/relational) + - MemoryManager : Unified interface that orchestrates all three +""" + +from cognitive_engine.memory.working import WorkingMemory +from cognitive_engine.memory.episodic import EpisodicMemory +from cognitive_engine.memory.semantic import SemanticMemory +from cognitive_engine.memory.memory_manager import MemoryManager + +__all__ = [ + "WorkingMemory", + "EpisodicMemory", + "SemanticMemory", + "MemoryManager", +] diff --git a/cognitive-engine/memory/episodic.py b/cognitive-engine/memory/episodic.py new file mode 100644 index 00000000..54cd20bd --- /dev/null +++ b/cognitive-engine/memory/episodic.py @@ -0,0 +1,227 @@ +""" +EpisodicMemory — Time-stamped Event Store for the DISHA Cognitive Engine. + +Episodic memory records specific events, interactions, and outcomes that occur +during sessions. It functions like a personal diary — each episode captures +what happened, the result, and its emotional/importance weight. + +Role in architecture: + EpisodicMemory feeds the _attend phase with relevant past experiences, + allowing the cognitive loop to learn from history. High-importance episodes + are periodically promoted to SemanticMemory by the MemoryManager. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import time +import uuid +from pathlib import Path +from typing import Any + +import aiofiles +import structlog + +log = structlog.get_logger(__name__) + +EPISODIC_STORE_PATH = Path.home() / ".disha" / "episodic.json" + + +class EpisodicMemory: + """ + Persistent time-stamped event store with fuzzy recall. + + Episodes are stored as dicts and persisted to JSON at + ~/.disha/episodic.json. Recall is performed via keyword overlap + scoring weighted by recency and importance. + """ + + def __init__(self, store_path: Path | None = None) -> None: + self._path = store_path or EPISODIC_STORE_PATH + self._episodes: list[dict[str, Any]] = [] + self._lock = asyncio.Lock() + self._loaded = False + log.debug("episodic_memory.initialized", path=str(self._path)) + + # ------------------------------------------------------------------ + # Lifecycle helpers + # ------------------------------------------------------------------ + + async def _ensure_loaded(self) -> None: + """Load from disk if not already loaded (lazy init).""" + if self._loaded: + return + async with self._lock: + if self._loaded: + return + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + if self._path.exists(): + async with aiofiles.open(self._path, "r") as f: + raw = await f.read() + self._episodes = json.loads(raw) if raw.strip() else [] + log.info( + "episodic_memory.loaded", + episodes=len(self._episodes), + path=str(self._path), + ) + else: + self._episodes = [] + except Exception as exc: + log.warning("episodic_memory.load_failed", error=str(exc)) + self._episodes = [] + self._loaded = True + + async def _persist(self) -> None: + """Atomically write episodes to disk.""" + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(self._path, "w") as f: + await f.write(json.dumps(self._episodes, indent=2, default=str)) + log.debug("episodic_memory.persisted", count=len(self._episodes)) + except Exception as exc: + log.error("episodic_memory.persist_failed", error=str(exc)) + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + async def record( + self, + session_id: str, + turn: int, + what: str, + outcome: str, + importance: float = 0.5, + emotions: list[str] | None = None, + ) -> str: + """ + Record a new episode. + + Args: + session_id: Session identifier. + turn: Cognitive turn number within the session. + what: Description of what happened. + outcome: Result or consequence of the event. + importance: Importance score in [0, 1]. Default 0.5. + emotions: Optional list of emotional tags (e.g. ["curious", "uncertain"]). + + Returns: + The generated episode_id (UUID string). + """ + await self._ensure_loaded() + + episode_id = str(uuid.uuid4()) + episode: dict[str, Any] = { + "episode_id": episode_id, + "session_id": session_id, + "turn": turn, + "what": what, + "outcome": outcome, + "emotions": emotions or [], + "importance": max(0.0, min(1.0, importance)), + "timestamp": time.time(), + } + + async with self._lock: + self._episodes.append(episode) + + await self._persist() + log.debug( + "episodic_memory.recorded", + episode_id=episode_id, + session_id=session_id, + turn=turn, + importance=importance, + ) + return episode_id + + async def recall(self, query: str, n: int = 5) -> list[dict[str, Any]]: + """ + Retrieve the top-n most relevant episodes using fuzzy keyword matching. + + Scoring = keyword_overlap_ratio * 0.5 + importance * 0.3 + recency_score * 0.2 + + Args: + query: Free-text query to match against episode content. + n: Maximum number of episodes to return. + + Returns: + List of episode dicts sorted by relevance score (descending). + """ + await self._ensure_loaded() + + if not self._episodes: + return [] + + query_tokens = set(query.lower().split()) + now = time.time() + max_age = 7 * 24 * 3600 # 1 week normalisation window + + scored: list[tuple[float, dict[str, Any]]] = [] + for ep in self._episodes: + # Keyword overlap score + ep_text = f"{ep.get('what', '')} {ep.get('outcome', '')}".lower() + ep_tokens = set(ep_text.split()) + overlap = len(query_tokens & ep_tokens) / max(len(query_tokens), 1) + + # Recency score (1.0 = just happened, 0.0 = very old) + age = now - ep.get("timestamp", now) + recency = max(0.0, 1.0 - age / max_age) + + score = overlap * 0.5 + ep.get("importance", 0.5) * 0.3 + recency * 0.2 + scored.append((score, ep)) + + scored.sort(key=lambda x: x[0], reverse=True) + results = [ep for _, ep in scored[:n]] + log.debug("episodic_memory.recalled", query=query[:50], results=len(results)) + return results + + def consolidate(self, min_importance: float = 0.7) -> list[dict[str, Any]]: + """ + Return all episodes above the importance threshold. + + These are candidates for promotion into SemanticMemory. + + Args: + min_importance: Minimum importance to include. Default 0.7. + + Returns: + List of high-importance episode dicts. + """ + candidates = [ep for ep in self._episodes if ep.get("importance", 0) >= min_importance] + log.debug( + "episodic_memory.consolidate", + candidates=len(candidates), + threshold=min_importance, + ) + return candidates + + def get_timeline(self, session_id: str) -> list[dict[str, Any]]: + """ + Return all episodes for a session in chronological order. + + Args: + session_id: The session to retrieve episodes for. + + Returns: + Chronologically sorted list of episode dicts. + """ + timeline = [ep for ep in self._episodes if ep.get("session_id") == session_id] + timeline.sort(key=lambda ep: ep.get("timestamp", 0)) + return timeline + + def stats(self) -> dict[str, Any]: + """Return summary statistics about the episodic store.""" + if not self._episodes: + return {"total_episodes": 0, "sessions": 0, "avg_importance": 0.0} + importances = [ep.get("importance", 0.5) for ep in self._episodes] + sessions = len({ep.get("session_id") for ep in self._episodes}) + return { + "total_episodes": len(self._episodes), + "sessions": sessions, + "avg_importance": round(sum(importances) / len(importances), 4), + "high_importance_count": sum(1 for i in importances if i >= 0.7), + } diff --git a/cognitive-engine/memory/memory_manager.py b/cognitive-engine/memory/memory_manager.py new file mode 100644 index 00000000..b544c5ec --- /dev/null +++ b/cognitive-engine/memory/memory_manager.py @@ -0,0 +1,282 @@ +""" +MemoryManager — Unified Memory Orchestrator for the DISHA Cognitive Engine. + +MemoryManager provides a single interface to all three memory subsystems: + - WorkingMemory : volatile attention buffer (in-process) + - EpisodicMemory : time-stamped event log (persisted to JSON) + - SemanticMemory : concept graph (persisted to JSON) + +It handles cross-memory operations such as promoting important episodic +memories into semantic memory (consolidation) and unified retrieval that +merges results from all three sources. + +Role in architecture: + CognitiveEngine holds one MemoryManager instance per session and calls it + during the _attend phase (retrieve) and _consolidate phase (store_episode, + learn_concept, promote_to_semantic). +""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any + +import structlog + +from cognitive_engine.memory.working import WorkingMemory +from cognitive_engine.memory.episodic import EpisodicMemory +from cognitive_engine.memory.semantic import SemanticMemory + +log = structlog.get_logger(__name__) + + +class MemoryManager: + """ + Unified interface to WorkingMemory, EpisodicMemory, and SemanticMemory. + + One instance is shared by the CognitiveEngine; the WorkingMemory is + session-scoped (in-process), while Episodic and Semantic stores persist + to disk across sessions. + """ + + def __init__(self) -> None: + self.working = WorkingMemory() + self.episodic = EpisodicMemory() + self.semantic = SemanticMemory() + log.info("memory_manager.initialized") + + # ------------------------------------------------------------------ + # Unified retrieval + # ------------------------------------------------------------------ + + async def retrieve(self, query: str, session_id: str) -> dict[str, Any]: + """ + Retrieve relevant memories from all three stores simultaneously. + + Args: + query: Free-text query string. + session_id: Active session identifier. + + Returns: + { + "working": [slot dicts], + "episodic": [episode dicts], + "semantic": [concept dict | None], + } + """ + log.debug("memory_manager.retrieve", query=query[:60], session_id=session_id) + + # Fan-out to episodic and semantic in parallel; working is synchronous + episodic_task = asyncio.create_task(self.episodic.recall(query, n=5)) + + # For semantic, try to match main keyword from query + keywords = [w for w in query.lower().split() if len(w) > 3] + semantic_results: list[dict[str, Any]] = [] + for kw in keywords[:3]: + concept = await self.semantic.query(kw) + if concept: + concept["name"] = kw + semantic_results.append(concept) + + episodic_results = await episodic_task + working_slots = self.working.get_context_window() + + result = { + "working": working_slots, + "episodic": episodic_results, + "semantic": semantic_results, + } + log.debug( + "memory_manager.retrieved", + working=len(working_slots), + episodic=len(episodic_results), + semantic=len(semantic_results), + ) + return result + + # ------------------------------------------------------------------ + # Write operations + # ------------------------------------------------------------------ + + async def store_episode( + self, + session_id: str, + turn: int, + content: str, + outcome: str, + importance: float = 0.5, + emotions: list[str] | None = None, + ) -> str: + """ + Record a new episode in episodic memory. + + Args: + session_id: Session identifier. + turn: Cognitive turn number. + content: What happened (input/action description). + outcome: Result or consequence. + importance: Importance score [0, 1]. + emotions: Optional emotional tags. + + Returns: + The generated episode_id. + """ + episode_id = await self.episodic.record( + session_id=session_id, + turn=turn, + what=content, + outcome=outcome, + importance=importance, + emotions=emotions, + ) + log.debug( + "memory_manager.episode_stored", + episode_id=episode_id, + session_id=session_id, + importance=importance, + ) + return episode_id + + async def learn_concept( + self, + concept: str, + definition: str, + relations: list[dict[str, Any]], + source: str, + ) -> None: + """ + Add or update a concept in semantic memory. + + Args: + concept: Concept name. + definition: Human-readable definition string. + relations: List of relationship dicts [{target, rel_type, confidence}]. + source: Provenance string. + """ + await self.semantic.learn( + concept=concept, + definition=definition, + relationships=relations, + source=source, + ) + log.debug( + "memory_manager.concept_learned", + concept=concept, + relations=len(relations), + source=source, + ) + + # ------------------------------------------------------------------ + # Consolidation + # ------------------------------------------------------------------ + + def promote_to_semantic(self, session_id: str) -> int: + """ + Consolidate high-importance episodic memories into semantic memory. + + Episodes with importance >= 0.7 are promoted synchronously. Each + episode's 'what' field is parsed for a simple concept/definition and + stored in semantic memory. + + Args: + session_id: The session whose episodes to promote. + + Returns: + Number of episodes promoted. + """ + candidates = self.episodic.consolidate(min_importance=0.7) + session_candidates = [ + ep for ep in candidates if ep.get("session_id") == session_id + ] + + promoted = 0 + for ep in session_candidates: + what = ep.get("what", "") + if not what or len(what.split()) < 3: + continue + # Derive a concept name from the first significant word + words = [w.strip(".,!?") for w in what.lower().split() if len(w) > 3] + if not words: + continue + concept = words[0] + definition = what + # Build a minimal relationship: this episode links to its outcome + outcome_words = [ + w.strip(".,!?") for w in ep.get("outcome", "").lower().split() if len(w) > 3 + ] + relations: list[dict[str, Any]] = [] + if outcome_words: + relations.append( + { + "target": outcome_words[0], + "rel_type": "leads_to", + "confidence": ep.get("importance", 0.7), + } + ) + + # Use asyncio.get_event_loop to schedule the coroutine + # (promote_to_semantic is called from synchronous context in consolidate phase) + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + asyncio.ensure_future( + self.semantic.learn( + concept=concept, + definition=definition, + relationships=relations, + source=f"episodic:{ep.get('episode_id', 'unknown')}", + ) + ) + else: + loop.run_until_complete( + self.semantic.learn( + concept=concept, + definition=definition, + relationships=relations, + source=f"episodic:{ep.get('episode_id', 'unknown')}", + ) + ) + promoted += 1 + except Exception as exc: + log.warning("memory_manager.promote_failed", error=str(exc), concept=concept) + + log.info( + "memory_manager.promoted_to_semantic", + session_id=session_id, + promoted=promoted, + candidates=len(session_candidates), + ) + return promoted + + # ------------------------------------------------------------------ + # Working memory helpers + # ------------------------------------------------------------------ + + def add_to_working(self, item: dict[str, Any], relevance: float) -> None: + """Add an item directly to working memory.""" + self.working.add(item, relevance) + + def decay_working_memory(self, factor: float = 0.95) -> None: + """Apply relevance decay to working memory slots.""" + self.working.decay(factor) + + # ------------------------------------------------------------------ + # Stats + # ------------------------------------------------------------------ + + def get_memory_stats(self) -> dict[str, Any]: + """ + Return aggregate statistics across all memory subsystems. + + Returns: + Dict with counts and stats for working, episodic, and semantic memory. + """ + stats = { + "working": self.working.stats(), + "episodic": self.episodic.stats(), + "semantic": self.semantic.stats(), + "timestamp": time.time(), + } + log.debug("memory_manager.stats", stats=stats) + return stats diff --git a/cognitive-engine/memory/semantic.py b/cognitive-engine/memory/semantic.py new file mode 100644 index 00000000..9fd38cc9 --- /dev/null +++ b/cognitive-engine/memory/semantic.py @@ -0,0 +1,306 @@ +""" +SemanticMemory — Concept Graph for the DISHA Cognitive Engine. + +Semantic memory stores long-term factual knowledge as a concept graph. +Each concept has a definition, typed relationships to other concepts, source +provenance, and a confidence score that slowly decays to model knowledge aging. + +Role in architecture: + SemanticMemory provides background world knowledge that enriches the + _attend phase. Important episodic episodes are periodically promoted into + semantic memory by the MemoryManager, allowing learned facts to persist + across sessions. BFS traversal enables multi-hop relational inference. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from collections import deque +from pathlib import Path +from typing import Any + +import aiofiles +import structlog + +log = structlog.get_logger(__name__) + +SEMANTIC_STORE_PATH = Path.home() / ".disha" / "semantic.json" + + +class SemanticMemory: + """ + Persistent concept graph with BFS traversal and confidence decay. + + The graph is stored as a dict: + concept_name -> { + definition: str, + relationships: [{target, rel_type, confidence}], + sources: [str], + last_updated: float, + confidence: float, + } + + Persists to ~/.disha/semantic.json. + """ + + def __init__(self, store_path: Path | None = None) -> None: + self._path = store_path or SEMANTIC_STORE_PATH + self._graph: dict[str, dict[str, Any]] = {} + self._lock = asyncio.Lock() + self._loaded = False + log.debug("semantic_memory.initialized", path=str(self._path)) + + # ------------------------------------------------------------------ + # Lifecycle helpers + # ------------------------------------------------------------------ + + async def _ensure_loaded(self) -> None: + """Lazy-load concept graph from disk.""" + if self._loaded: + return + async with self._lock: + if self._loaded: + return + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + if self._path.exists(): + async with aiofiles.open(self._path, "r") as f: + raw = await f.read() + self._graph = json.loads(raw) if raw.strip() else {} + log.info( + "semantic_memory.loaded", + concepts=len(self._graph), + path=str(self._path), + ) + else: + self._graph = {} + except Exception as exc: + log.warning("semantic_memory.load_failed", error=str(exc)) + self._graph = {} + self._loaded = True + + async def _persist(self) -> None: + """Write graph to disk atomically.""" + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(self._path, "w") as f: + await f.write(json.dumps(self._graph, indent=2, default=str)) + log.debug("semantic_memory.persisted", concepts=len(self._graph)) + except Exception as exc: + log.error("semantic_memory.persist_failed", error=str(exc)) + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + async def learn( + self, + concept: str, + definition: str, + relationships: list[dict[str, Any]], + source: str, + ) -> None: + """ + Add or update a concept in the graph. + + If the concept already exists, the definition is updated (if non-empty), + new relationships are merged (deduped by target+rel_type), and the source + is added to the provenance list. + + Args: + concept: Concept name (key in graph). + definition: Human-readable definition. + relationships: List of dicts: [{target, rel_type, confidence}]. + source: Provenance string (e.g. session_id or "bootstrap"). + """ + await self._ensure_loaded() + + concept = concept.strip().lower() + + # Validate and normalize relationships + valid_rels: list[dict[str, Any]] = [] + for rel in relationships: + if "target" in rel and "rel_type" in rel: + valid_rels.append( + { + "target": str(rel["target"]).strip().lower(), + "rel_type": str(rel["rel_type"]), + "confidence": float(rel.get("confidence", 0.8)), + } + ) + + async with self._lock: + if concept in self._graph: + node = self._graph[concept] + if definition: + node["definition"] = definition + # Merge relationships (avoid duplicates by target+rel_type) + existing_keys = { + (r["target"], r["rel_type"]) for r in node.get("relationships", []) + } + for rel in valid_rels: + key = (rel["target"], rel["rel_type"]) + if key not in existing_keys: + node.setdefault("relationships", []).append(rel) + existing_keys.add(key) + else: + # Update confidence if higher + for r in node["relationships"]: + if (r["target"], r["rel_type"]) == key: + r["confidence"] = max(r["confidence"], rel["confidence"]) + if source not in node.get("sources", []): + node.setdefault("sources", []).append(source) + node["last_updated"] = time.time() + else: + self._graph[concept] = { + "definition": definition, + "relationships": valid_rels, + "sources": [source], + "last_updated": time.time(), + "confidence": 1.0, + } + + await self._persist() + log.debug( + "semantic_memory.learned", + concept=concept, + relationships=len(valid_rels), + source=source, + ) + + async def query(self, concept: str) -> dict[str, Any] | None: + """ + Retrieve a concept node with its full relationship graph. + + Args: + concept: The concept to look up. + + Returns: + Concept dict or None if not found. + """ + await self._ensure_loaded() + concept = concept.strip().lower() + result = self._graph.get(concept) + log.debug("semantic_memory.query", concept=concept, found=result is not None) + return result + + async def find_related(self, concept: str, depth: int = 2) -> list[dict[str, Any]]: + """ + BFS traversal from a concept up to the given depth. + + Args: + concept: Starting concept. + depth: Maximum BFS depth (hops). + + Returns: + List of related concept dicts, each augmented with 'name' and 'hops'. + """ + await self._ensure_loaded() + concept = concept.strip().lower() + + if concept not in self._graph: + return [] + + visited: set[str] = {concept} + queue: deque[tuple[str, int]] = deque([(concept, 0)]) + related: list[dict[str, Any]] = [] + + while queue: + current, hops = queue.popleft() + if hops >= depth: + continue + node = self._graph.get(current, {}) + for rel in node.get("relationships", []): + target = rel["target"] + if target not in visited and target in self._graph: + visited.add(target) + entry = dict(self._graph[target]) + entry["name"] = target + entry["hops"] = hops + 1 + entry["via_relation"] = rel["rel_type"] + related.append(entry) + queue.append((target, hops + 1)) + + log.debug( + "semantic_memory.find_related", + concept=concept, + depth=depth, + found=len(related), + ) + return related + + async def infer(self, concept_a: str, concept_b: str) -> str | None: + """ + Check whether a path exists between two concepts. + + Uses BFS to find the shortest path. If found, returns a human-readable + description of the relationship chain. Returns None if disconnected. + + Args: + concept_a: Source concept. + concept_b: Target concept. + + Returns: + A string describing the relationship path, or None. + """ + await self._ensure_loaded() + ca = concept_a.strip().lower() + cb = concept_b.strip().lower() + + if ca not in self._graph or cb not in self._graph: + return None + if ca == cb: + return f"'{ca}' is identical to '{cb}'" + + # BFS with path tracking + queue: deque[tuple[str, list[tuple[str, str]]]] = deque([(ca, [])]) + visited: set[str] = {ca} + + while queue: + current, path = queue.popleft() + node = self._graph.get(current, {}) + for rel in node.get("relationships", []): + target = rel["target"] + new_path = path + [(rel["rel_type"], target)] + if target == cb: + # Build human-readable inference + parts = [ca] + for rel_type, t in new_path: + parts.append(f"--[{rel_type}]--> {t}") + inference = " ".join(parts) + log.debug("semantic_memory.inferred", path=inference) + return inference + if target not in visited and target in self._graph: + visited.add(target) + queue.append((target, new_path)) + + log.debug("semantic_memory.no_inference", concept_a=ca, concept_b=cb) + return None + + def decay_confidence(self, factor: float = 0.999) -> None: + """ + Slowly reduce the confidence of all concepts to model knowledge aging. + + Args: + factor: Multiplicative decay per call. Default 0.999 (slow decay). + """ + count = 0 + for node in self._graph.values(): + node["confidence"] = max(0.0, node.get("confidence", 1.0) * factor) + count += 1 + log.debug("semantic_memory.confidence_decayed", concepts=count, factor=factor) + + def stats(self) -> dict[str, Any]: + """Return summary statistics about the semantic graph.""" + if not self._graph: + return {"total_concepts": 0, "total_relationships": 0, "avg_confidence": 0.0} + total_rels = sum(len(n.get("relationships", [])) for n in self._graph.values()) + confidences = [n.get("confidence", 1.0) for n in self._graph.values()] + return { + "total_concepts": len(self._graph), + "total_relationships": total_rels, + "avg_confidence": round(sum(confidences) / len(confidences), 4), + "min_confidence": round(min(confidences), 4), + } diff --git a/cognitive-engine/memory/working.py b/cognitive-engine/memory/working.py new file mode 100644 index 00000000..b66ae6c8 --- /dev/null +++ b/cognitive-engine/memory/working.py @@ -0,0 +1,153 @@ +""" +WorkingMemory — Attention Buffer for the DISHA Cognitive Engine. + +Implements the 8-slot working memory model inspired by cognitive science research +on human short-term memory capacity (Miller's Law). Each slot holds an active +attention item with a relevance score that decays over time. + +Role in architecture: + WorkingMemory acts as the 'attentional spotlight' — only the most relevant + context items are kept active during a cognitive turn, preventing overload + and forcing prioritization. +""" + +from __future__ import annotations + +import time +from typing import Any +import structlog + +log = structlog.get_logger(__name__) + +MAX_SLOTS = 8 + + +class WorkingMemory: + """ + Fixed-capacity attention buffer implementing a relevance-gated memory store. + + Maintains up to MAX_SLOTS (8) active memory items. When full, the lowest- + relevance item is evicted to make room for new items. Relevance scores decay + each turn to model forgetting and keep recent information prioritized. + """ + + def __init__(self) -> None: + self._slots: list[dict[str, Any]] = [] + log.debug("working_memory.initialized", max_slots=MAX_SLOTS) + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def add(self, item: dict[str, Any], relevance: float) -> None: + """ + Add an item to working memory with the given relevance score. + + If the buffer is full (8 slots), evicts the item with the lowest + relevance score before inserting the new one. + + Args: + item: Arbitrary dict describing the memory content. + relevance: Float in [0, 1] indicating how relevant this item is. + """ + relevance = max(0.0, min(1.0, relevance)) + + entry: dict[str, Any] = { + "content": item.get("content", item), + "relevance": relevance, + "source": item.get("source", "unknown"), + "added_at": time.time(), + "metadata": {k: v for k, v in item.items() if k not in ("content", "source")}, + } + + if len(self._slots) >= MAX_SLOTS: + # Evict the slot with the lowest relevance + min_idx = min(range(len(self._slots)), key=lambda i: self._slots[i]["relevance"]) + evicted = self._slots.pop(min_idx) + log.debug( + "working_memory.evicted", + evicted_source=evicted.get("source"), + evicted_relevance=round(evicted["relevance"], 3), + new_item_source=entry["source"], + ) + + self._slots.append(entry) + log.debug( + "working_memory.added", + source=entry["source"], + relevance=round(relevance, 3), + slot_count=len(self._slots), + ) + + def get_context_window(self) -> list[dict[str, Any]]: + """ + Return all active slots sorted by relevance (highest first). + + Returns: + List of slot dicts ordered from most to least relevant. + """ + return sorted(self._slots, key=lambda s: s["relevance"], reverse=True) + + def clear_below(self, threshold: float) -> int: + """ + Remove all slots whose relevance falls below the given threshold. + + Args: + threshold: Minimum relevance to keep. Items below this are dropped. + + Returns: + Number of items removed. + """ + before = len(self._slots) + self._slots = [s for s in self._slots if s["relevance"] >= threshold] + removed = before - len(self._slots) + if removed: + log.debug( + "working_memory.cleared_below_threshold", + threshold=threshold, + removed=removed, + remaining=len(self._slots), + ) + return removed + + def decay(self, factor: float = 0.95) -> None: + """ + Decay all relevance scores by the given multiplicative factor. + + Simulates the natural fading of attention over time. Called once + per cognitive turn to ensure stale items gradually lose priority. + + Args: + factor: Multiplicative decay factor in (0, 1]. Default 0.95. + """ + for slot in self._slots: + slot["relevance"] = max(0.0, slot["relevance"] * factor) + log.debug("working_memory.decayed", factor=factor, slot_count=len(self._slots)) + + def clear_all(self) -> None: + """Remove all items from working memory.""" + self._slots.clear() + log.debug("working_memory.cleared_all") + + def __len__(self) -> int: + return len(self._slots) + + def __repr__(self) -> str: + return f"WorkingMemory(slots={len(self._slots)}/{MAX_SLOTS})" + + # ------------------------------------------------------------------ + # Stats / introspection + # ------------------------------------------------------------------ + + def stats(self) -> dict[str, Any]: + """Return a summary of current memory state.""" + if not self._slots: + return {"count": 0, "avg_relevance": 0.0, "max_relevance": 0.0, "min_relevance": 0.0} + relevances = [s["relevance"] for s in self._slots] + return { + "count": len(self._slots), + "capacity": MAX_SLOTS, + "avg_relevance": round(sum(relevances) / len(relevances), 4), + "max_relevance": round(max(relevances), 4), + "min_relevance": round(min(relevances), 4), + } diff --git a/cognitive-engine/requirements.txt b/cognitive-engine/requirements.txt new file mode 100644 index 00000000..1e240c45 --- /dev/null +++ b/cognitive-engine/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +websockets>=13.0 +structlog>=24.4.0 +pydantic>=2.9.0 diff --git a/cognitive-engine/tests/__init__.py b/cognitive-engine/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cognitive-engine/tests/test_cognitive_loop.py b/cognitive-engine/tests/test_cognitive_loop.py new file mode 100644 index 00000000..3d9fed49 --- /dev/null +++ b/cognitive-engine/tests/test_cognitive_loop.py @@ -0,0 +1,327 @@ +""" +Unit tests for the DISHA-MIND cognitive loop engine. + +Tests cover: +- CognitiveState dataclass integrity +- All 7 cognitive stage methods in isolation +- Full cognitive cycle (end-to-end) +- Confidence threshold / clarification branch +- Session tracing and introspection +""" + +from __future__ import annotations + +import asyncio +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from cognitive_engine.cognitive_loop import CognitiveEngine, CognitiveState + + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def state(): + return CognitiveState( + session_id="test-session", + turn=0, + raw_input="Analyze the domain evil.io for threats", + ) + + +@pytest.fixture +def engine(): + """Engine with mocked sub-components to avoid network/disk I/O.""" + with ( + patch("cognitive_engine.cognitive_loop.MemoryManager") as MockMem, + patch("cognitive_engine.cognitive_loop.AgentDeliberator") as MockDel, + patch("cognitive_engine.cognitive_loop.HybridReasoner") as MockReas, + patch("cognitive_engine.cognitive_loop.QuantumDecisionEngine") as MockQD, + patch("cognitive_engine.cognitive_loop.GoalEngine") as MockGE, + ): + # Memory mock + mem = MockMem.return_value + mem.retrieve = AsyncMock(return_value={ + "working": [], + "episodic": [{"content": "prior event", "importance": 0.8}], + "semantic": [{"concept": "evil.io", "definition": "malicious domain"}], + }) + mem.working = MagicMock() + mem.working.decay = MagicMock() + mem.working.add = MagicMock() + mem.store_episode = AsyncMock() + mem.promote_to_semantic = MagicMock(return_value=1) + mem.learn_concept = AsyncMock() + mem.get_memory_stats = MagicMock(return_value={"working": 1, "episodic": 5, "semantic": 3}) + + # Deliberation mock + del_ = MockDel.return_value + del_.deliberate = AsyncMock(return_value={ + "all_opinions": [ + {"agent": "planner", "confidence": 0.8, "recommendation": "Block the domain"}, + {"agent": "executor", "confidence": 0.75, "recommendation": "Quarantine host"}, + {"agent": "critic", "confidence": 0.7, "recommendation": "Investigate further"}, + ], + "winner": {"recommendation": "Block the domain", "confidence": 0.8}, + "dissenting_view": {"agent": "critic", "recommendation": "Investigate further"}, + }) + + # Reasoner mock + reas = MockReas.return_value + + class FakeHypothesis: + mode = "deductive" + hypothesis = "evil.io is a C2 server" + confidence = 0.82 + chain_of_thought = ["It resolves to a known bad IP", "Pattern matches APT42"] + + def __iter__(self): + return iter(self.__dict__.items()) + + hyp = FakeHypothesis() + reas.reason = AsyncMock(return_value=[hyp]) + reas.select_best = MagicMock(return_value=hyp) + + # GoalEngine mock + ge = MockGE.return_value + ge.get_goal_tree = MagicMock(return_value={}) + + eng = CognitiveEngine() + yield eng + + +# ── CognitiveState tests ─────────────────────────────────────────────────────── + + +class TestCognitiveState: + def test_defaults(self, state): + assert state.intent == "" + assert state.entities == [] + assert state.uncertainty == 0.5 + assert state.working_memory == [] + assert state.hypotheses == [] + assert state.confidence == 0.0 + assert state.action is None + assert state.reflection is None + + def test_to_dict_contains_all_fields(self, state): + d = state.to_dict() + for key in [ + "session_id", "turn", "raw_input", "intent", "entities", + "uncertainty", "working_memory", "hypotheses", "agent_deliberations", + "action", "confidence", "reflection", "learning_triggers", + "consolidated_episodes", "new_concepts_learned", + ]: + assert key in d, f"Missing key in to_dict(): {key}" + + +# ── Stage isolation tests ────────────────────────────────────────────────────── + + +class TestPerceiveStage: + @pytest.mark.asyncio + async def test_intent_classification(self, engine, state): + await engine._perceive(state) + assert state.intent == "threat" # "domain" triggers threat map + + @pytest.mark.asyncio + async def test_entity_extraction_ip(self, engine): + s = CognitiveState(session_id="s", turn=0, raw_input="Check 192.168.1.1 now") + await engine._perceive(s) + assert "192.168.1.1" in s.entities + + @pytest.mark.asyncio + async def test_uncertainty_long_input(self, engine): + long_input = "word " * 30 + s = CognitiveState(session_id="s", turn=0, raw_input=long_input) + await engine._perceive(s) + assert s.uncertainty <= 0.2 # long input → low uncertainty + + @pytest.mark.asyncio + async def test_uncertainty_short_input(self, engine): + s = CognitiveState(session_id="s", turn=0, raw_input="help") + await engine._perceive(s) + assert s.uncertainty >= 0.7 # short input → high uncertainty + + @pytest.mark.asyncio + async def test_entities_capped_at_10(self, engine): + raw = " ".join(f"192.168.1.{i}" for i in range(20)) + s = CognitiveState(session_id="s", turn=0, raw_input=raw) + await engine._perceive(s) + assert len(s.entities) <= 10 + + +class TestAttendStage: + @pytest.mark.asyncio + async def test_working_memory_populated(self, engine, state): + state.intent = "threat" + await engine._attend(state) + # Memory retrieve was called + engine.memory.retrieve.assert_called_once() + + @pytest.mark.asyncio + async def test_episodes_recalled(self, engine, state): + state.intent = "threat" + await engine._attend(state) + assert len(state.recalled_episodes) > 0 + + @pytest.mark.asyncio + async def test_decay_called(self, engine, state): + await engine._attend(state) + engine.memory.working.decay.assert_called_once_with(CognitiveEngine.WORKING_MEMORY_DECAY) + + +class TestReasonStage: + @pytest.mark.asyncio + async def test_hypotheses_populated(self, engine, state): + state.intent = "threat" + state.entities = ["evil.io"] + state.recalled_episodes = [{"content": "prior event"}] + state.recalled_concepts = [] + state.uncertainty = 0.3 + await engine._reason(state) + assert len(state.hypotheses) >= 1 + + @pytest.mark.asyncio + async def test_selected_hypothesis_is_dict(self, engine, state): + state.intent = "threat" + state.entities = [] + state.recalled_episodes = [] + state.recalled_concepts = [] + state.uncertainty = 0.5 + await engine._reason(state) + assert isinstance(state.selected_hypothesis, dict) + + +class TestActStage: + @pytest.mark.asyncio + async def test_high_confidence_produces_action(self, engine, state): + state.selected_hypothesis = {"confidence": 0.9, "hypothesis": "C2 server", "mode": "deductive"} + state.consensus = {"confidence": 0.85, "recommendation": "Block domain"} + state.intent = "threat" + state.entities = ["evil.io"] + await engine._act(state) + assert state.action is not None + assert state.action["type"] == "threat" + assert state.confidence > CognitiveEngine.CONFIDENCE_THRESHOLD + + @pytest.mark.asyncio + async def test_low_confidence_produces_clarification(self, engine, state): + state.selected_hypothesis = {"confidence": 0.1, "hypothesis": "Unknown", "mode": "abductive"} + state.consensus = {"confidence": 0.1, "recommendation": ""} + state.intent = "general" + state.entities = [] + await engine._act(state) + assert state.action["type"] == "clarification_request" + + @pytest.mark.asyncio + async def test_confidence_is_average(self, engine, state): + state.selected_hypothesis = {"confidence": 0.6} + state.consensus = {"confidence": 0.8} + state.intent = "investigate" + state.entities = [] + await engine._act(state) + assert abs(state.confidence - 0.7) < 0.001 + + +class TestReflectStage: + @pytest.mark.asyncio + async def test_reflection_populated(self, engine, state): + state.agent_deliberations = [ + {"confidence": 0.8}, {"confidence": 0.75} + ] + state.confidence = 0.77 + state.recalled_episodes = [{"content": "x"}] + state.recalled_concepts = [] + state.hypotheses = [{"mode": "deductive"}, {"mode": "inductive"}, {"mode": "abductive"}] + await engine._reflect(state) + assert state.reflection is not None + assert 0.0 <= state.reflection["quality"] <= 1.0 + + @pytest.mark.asyncio + async def test_low_confidence_triggers_learning(self, engine, state): + state.agent_deliberations = [] + state.confidence = 0.2 + state.recalled_episodes = [] + state.recalled_concepts = [] + state.hypotheses = [] + await engine._reflect(state) + assert "low_confidence" in state.learning_triggers + + +class TestConsolidateStage: + @pytest.mark.asyncio + async def test_store_episode_called(self, engine, state): + state.reflection = {"quality": 0.8} + state.confidence = 0.75 + state.action = {"type": "threat"} + state.entities = ["evil.io"] + await engine._consolidate(state) + engine.memory.store_episode.assert_called_once() + + @pytest.mark.asyncio + async def test_entities_trigger_concept_learning(self, engine, state): + state.reflection = {"quality": 0.7} + state.confidence = 0.7 + state.action = {"type": "threat"} + state.entities = ["evil.io", "192.168.1.1"] + state.intent = "threat" + await engine._consolidate(state) + assert engine.memory.learn_concept.call_count >= 1 + + +# ── Full cycle tests ─────────────────────────────────────────────────────────── + + +class TestFullCycle: + @pytest.mark.asyncio + async def test_process_returns_state(self, engine): + state = await engine.process("Check domain evil.io for malware", session_id="sess1") + assert isinstance(state, CognitiveState) + assert state.session_id == "sess1" + assert state.turn == 0 + + @pytest.mark.asyncio + async def test_turn_increments(self, engine): + s1 = await engine.process("First query", session_id="sess2") + s2 = await engine.process("Second query", session_id="sess2") + assert s1.turn == 0 + assert s2.turn == 1 + + @pytest.mark.asyncio + async def test_stage_durations_recorded(self, engine): + state = await engine.process("Any input", session_id="sess3") + for stage in ["perceive", "attend", "reason", "deliberate", "act", "reflect", "consolidate"]: + assert stage in state.stage_durations + assert state.stage_durations[stage] >= 0 + + @pytest.mark.asyncio + async def test_auto_session_id(self, engine): + state = await engine.process("Hello") + assert state.session_id # non-empty + assert len(state.session_id) > 8 + + +# ── Introspection tests ──────────────────────────────────────────────────────── + + +class TestIntrospection: + @pytest.mark.asyncio + async def test_get_introspection_report(self, engine): + await engine.process("Input A", session_id="intro-sess") + await engine.process("Input B", session_id="intro-sess") + report = engine.get_introspection_report("intro-sess") + assert report["session_id"] == "intro-sess" + assert report["total_turns"] == 2 + assert len(report["turns"]) == 2 + + def test_get_session_ids(self, engine): + ids = engine.get_session_ids() + assert isinstance(ids, list) + + @pytest.mark.asyncio + async def test_unknown_session_returns_empty(self, engine): + report = engine.get_introspection_report("nonexistent-session") + assert report["total_turns"] == 0 + assert report["turns"] == []