caudate-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
memory/__init__.py ADDED
File without changes
memory/episodic.py ADDED
@@ -0,0 +1,99 @@
1
+ """Episodic Memory — stores and retrieves past experiences using vector similarity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ import chromadb
10
+
11
+ from config import CHROMA_DIR, EPISODIC_RECALL_LIMIT
12
+ from core.schemas import Episode
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class EpisodicMemory:
18
+ """Vector-based store of past episodes (experiences).
19
+
20
+ Each episode records what the agent did, what happened, and in what context.
21
+ Retrieval is by semantic similarity to a query.
22
+ """
23
+
24
+ def __init__(self, persist_dir: str | None = None):
25
+ path = persist_dir or str(CHROMA_DIR)
26
+ self._client = chromadb.PersistentClient(path=path)
27
+ self._collection = self._client.get_or_create_collection(
28
+ name="episodic_memory",
29
+ metadata={"hnsw:space": "cosine"},
30
+ )
31
+
32
+ def store(self, episode: Episode) -> None:
33
+ """Store an episode."""
34
+ text = self._episode_to_text(episode)
35
+ metadata = {
36
+ "goal_id": episode.goal_id,
37
+ "task_id": episode.task_id,
38
+ "tool_name": episode.tool_name or "",
39
+ "timestamp": episode.timestamp.isoformat(),
40
+ }
41
+ if episode.result:
42
+ metadata["result_status"] = episode.result.status.value
43
+
44
+ self._collection.add(
45
+ ids=[episode.id],
46
+ documents=[text],
47
+ metadatas=[metadata],
48
+ )
49
+ logger.debug(f"Stored episode {episode.id}")
50
+
51
+ def recall(
52
+ self,
53
+ query: str,
54
+ limit: int = EPISODIC_RECALL_LIMIT,
55
+ goal_id: str | None = None,
56
+ ) -> list[Episode]:
57
+ """Retrieve similar episodes."""
58
+ where = {"goal_id": goal_id} if goal_id else None
59
+ results = self._collection.query(
60
+ query_texts=[query],
61
+ n_results=limit,
62
+ where=where,
63
+ )
64
+
65
+ episodes = []
66
+ if results["documents"]:
67
+ for i, doc in enumerate(results["documents"][0]):
68
+ meta = results["metadatas"][0][i] if results["metadatas"] else {}
69
+ kwargs = {
70
+ "id": results["ids"][0][i],
71
+ "goal_id": meta.get("goal_id", ""),
72
+ "task_id": meta.get("task_id", ""),
73
+ "action": doc,
74
+ "tool_name": meta.get("tool_name") or None,
75
+ }
76
+ ts = meta.get("timestamp")
77
+ if ts:
78
+ kwargs["timestamp"] = ts
79
+ episode = Episode(**kwargs)
80
+ episodes.append(episode)
81
+ return episodes
82
+
83
+ def count(self) -> int:
84
+ return self._collection.count()
85
+
86
+ def _episode_to_text(self, episode: Episode) -> str:
87
+ """Convert episode to searchable text."""
88
+ parts = [f"Action: {episode.action}"]
89
+ if episode.tool_name:
90
+ parts.append(f"Tool: {episode.tool_name}")
91
+ if episode.tool_args:
92
+ parts.append(f"Args: {json.dumps(episode.tool_args)}")
93
+ if episode.result:
94
+ parts.append(f"Result: {episode.result.status.value}")
95
+ if episode.result.output:
96
+ parts.append(f"Output: {str(episode.result.output)[:500]}")
97
+ if episode.result.error:
98
+ parts.append(f"Error: {episode.result.error}")
99
+ return " | ".join(parts)
memory/procedural.py ADDED
@@ -0,0 +1,145 @@
1
+ """Procedural Memory — stores learned strategies and heuristics in SQLite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from datetime import datetime
8
+
9
+ import sqlalchemy as sa
10
+ from sqlalchemy import create_engine, Column, String, Float, Integer, DateTime, Text
11
+ from sqlalchemy.orm import declarative_base, Session
12
+
13
+ from config import SQLITE_PATH
14
+ from core.schemas import Strategy
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ Base = declarative_base()
19
+
20
+
21
+ class StrategyRow(Base):
22
+ __tablename__ = "strategies"
23
+
24
+ id = Column(String, primary_key=True)
25
+ name = Column(String, nullable=False)
26
+ description = Column(Text, nullable=False)
27
+ conditions = Column(Text, nullable=False)
28
+ actions = Column(Text, nullable=False)
29
+ confidence = Column(Float, default=0.5)
30
+ times_used = Column(Integer, default=0)
31
+ times_succeeded = Column(Integer, default=0)
32
+ created_at = Column(DateTime, default=datetime.utcnow)
33
+ updated_at = Column(DateTime, default=datetime.utcnow)
34
+
35
+
36
+ class PerformanceLogRow(Base):
37
+ __tablename__ = "performance_log"
38
+
39
+ id = Column(Integer, primary_key=True, autoincrement=True)
40
+ goal_id = Column(String, nullable=False)
41
+ score = Column(Float, nullable=False)
42
+ lesson = Column(Text, default="")
43
+ strategy_ids = Column(Text, default="[]") # JSON list
44
+ timestamp = Column(DateTime, default=datetime.utcnow)
45
+
46
+
47
+ class ProceduralMemory:
48
+ """SQLite-backed store for learned strategies and performance history."""
49
+
50
+ def __init__(self, db_path: str | None = None):
51
+ path = db_path or str(SQLITE_PATH)
52
+ self._engine = create_engine(f"sqlite:///{path}")
53
+ Base.metadata.create_all(self._engine)
54
+
55
+ def store_strategy(self, strategy: Strategy) -> None:
56
+ """Store or update a strategy."""
57
+ with Session(self._engine) as session:
58
+ row = session.get(StrategyRow, strategy.id)
59
+ if row:
60
+ row.name = strategy.name
61
+ row.description = strategy.description
62
+ row.conditions = strategy.conditions
63
+ row.actions = strategy.actions
64
+ row.confidence = strategy.confidence
65
+ row.times_used = strategy.times_used
66
+ row.times_succeeded = strategy.times_succeeded
67
+ row.updated_at = datetime.utcnow()
68
+ else:
69
+ row = StrategyRow(
70
+ id=strategy.id,
71
+ name=strategy.name,
72
+ description=strategy.description,
73
+ conditions=strategy.conditions,
74
+ actions=strategy.actions,
75
+ confidence=strategy.confidence,
76
+ times_used=strategy.times_used,
77
+ times_succeeded=strategy.times_succeeded,
78
+ )
79
+ session.add(row)
80
+ session.commit()
81
+
82
+ def get_strategies(self, min_confidence: float = 0.0) -> list[Strategy]:
83
+ """Retrieve all strategies above a confidence threshold."""
84
+ with Session(self._engine) as session:
85
+ rows = session.query(StrategyRow).filter(
86
+ StrategyRow.confidence >= min_confidence
87
+ ).order_by(StrategyRow.confidence.desc()).all()
88
+
89
+ return [
90
+ Strategy(
91
+ id=r.id,
92
+ name=r.name,
93
+ description=r.description,
94
+ conditions=r.conditions,
95
+ actions=r.actions,
96
+ confidence=r.confidence,
97
+ times_used=r.times_used,
98
+ times_succeeded=r.times_succeeded,
99
+ created_at=r.created_at,
100
+ updated_at=r.updated_at,
101
+ )
102
+ for r in rows
103
+ ]
104
+
105
+ def update_strategy_stats(self, strategy_id: str, succeeded: bool) -> None:
106
+ """Update usage stats for a strategy after it's been applied."""
107
+ with Session(self._engine) as session:
108
+ row = session.get(StrategyRow, strategy_id)
109
+ if row:
110
+ row.times_used += 1
111
+ if succeeded:
112
+ row.times_succeeded += 1
113
+ # Recalculate confidence as success rate with Bayesian smoothing
114
+ row.confidence = (row.times_succeeded + 1) / (row.times_used + 2)
115
+ row.updated_at = datetime.utcnow()
116
+ session.commit()
117
+
118
+ def log_performance(self, goal_id: str, score: float, lesson: str, strategy_ids: list[str]) -> None:
119
+ """Log a performance entry."""
120
+ with Session(self._engine) as session:
121
+ session.add(PerformanceLogRow(
122
+ goal_id=goal_id,
123
+ score=score,
124
+ lesson=lesson,
125
+ strategy_ids=json.dumps(strategy_ids),
126
+ ))
127
+ session.commit()
128
+
129
+ def get_performance_history(self, limit: int = 20) -> list[dict]:
130
+ """Get recent performance entries."""
131
+ with Session(self._engine) as session:
132
+ rows = session.query(PerformanceLogRow).order_by(
133
+ PerformanceLogRow.timestamp.desc()
134
+ ).limit(limit).all()
135
+
136
+ return [
137
+ {
138
+ "goal_id": r.goal_id,
139
+ "score": r.score,
140
+ "lesson": r.lesson,
141
+ "strategy_ids": json.loads(r.strategy_ids),
142
+ "timestamp": r.timestamp.isoformat(),
143
+ }
144
+ for r in rows
145
+ ]
memory/semantic.py ADDED
@@ -0,0 +1,71 @@
1
+ """Semantic Memory — long-term knowledge base using vector similarity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ import chromadb
8
+
9
+ from config import CHROMA_DIR, SEMANTIC_RECALL_LIMIT
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SemanticMemory:
15
+ """Vector-based knowledge store.
16
+
17
+ Stores facts, concepts, and learned information that persists across goals.
18
+ Unlike episodic memory (what happened), semantic memory stores what is known.
19
+ """
20
+
21
+ def __init__(self, persist_dir: str | None = None):
22
+ path = persist_dir or str(CHROMA_DIR)
23
+ self._client = chromadb.PersistentClient(path=path)
24
+ self._collection = self._client.get_or_create_collection(
25
+ name="semantic_memory",
26
+ metadata={"hnsw:space": "cosine"},
27
+ )
28
+
29
+ def store(self, knowledge_id: str, content: str, category: str = "general") -> None:
30
+ """Store a piece of knowledge."""
31
+ self._collection.upsert(
32
+ ids=[knowledge_id],
33
+ documents=[content],
34
+ metadatas=[{"category": category}],
35
+ )
36
+ logger.debug(f"Stored knowledge: {knowledge_id}")
37
+
38
+ def recall(
39
+ self,
40
+ query: str,
41
+ limit: int = SEMANTIC_RECALL_LIMIT,
42
+ category: str | None = None,
43
+ ) -> list[dict[str, str]]:
44
+ """Retrieve relevant knowledge."""
45
+ where = {"category": category} if category else None
46
+ results = self._collection.query(
47
+ query_texts=[query],
48
+ n_results=limit,
49
+ where=where,
50
+ )
51
+
52
+ knowledge = []
53
+ if results["documents"]:
54
+ for i, doc in enumerate(results["documents"][0]):
55
+ meta = results["metadatas"][0][i] if results["metadatas"] else {}
56
+ knowledge.append({
57
+ "id": results["ids"][0][i],
58
+ "content": doc,
59
+ "category": meta.get("category", "general"),
60
+ })
61
+ return knowledge
62
+
63
+ def forget(self, knowledge_id: str) -> None:
64
+ """Remove a piece of knowledge."""
65
+ try:
66
+ self._collection.delete(ids=[knowledge_id])
67
+ except Exception:
68
+ pass
69
+
70
+ def count(self) -> int:
71
+ return self._collection.count()
memory/working.py ADDED
@@ -0,0 +1,64 @@
1
+ """Working Memory — short-term scratchpad for the current cognitive cycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections import OrderedDict
7
+ from typing import Any
8
+
9
+ from config import WORKING_MEMORY_CAPACITY
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class WorkingMemory:
15
+ """Fixed-capacity short-term memory. Oldest items are evicted when full."""
16
+
17
+ def __init__(self, capacity: int = WORKING_MEMORY_CAPACITY):
18
+ self.capacity = capacity
19
+ self._store: OrderedDict[str, Any] = OrderedDict()
20
+
21
+ def store(self, key: str, value: Any) -> None:
22
+ """Store an item. Evicts oldest if at capacity."""
23
+ if key in self._store:
24
+ self._store.move_to_end(key)
25
+ self._store[key] = value
26
+ while len(self._store) > self.capacity:
27
+ evicted_key, _ = self._store.popitem(last=False)
28
+ logger.debug(f"Working memory evicted: {evicted_key}")
29
+
30
+ def recall(self, key: str) -> Any | None:
31
+ """Recall an item by key."""
32
+ if key in self._store:
33
+ self._store.move_to_end(key)
34
+ return self._store[key]
35
+ return None
36
+
37
+ def recall_all(self) -> dict[str, Any]:
38
+ """Return all items in working memory."""
39
+ return dict(self._store)
40
+
41
+ def remove(self, key: str) -> None:
42
+ """Remove an item."""
43
+ self._store.pop(key, None)
44
+
45
+ def clear(self) -> None:
46
+ """Clear all working memory."""
47
+ self._store.clear()
48
+
49
+ def contains(self, key: str) -> bool:
50
+ return key in self._store
51
+
52
+ @property
53
+ def size(self) -> int:
54
+ return len(self._store)
55
+
56
+ def summary(self) -> str:
57
+ """Return a text summary of working memory contents."""
58
+ if not self._store:
59
+ return "Working memory is empty."
60
+ lines = [f"Working memory ({self.size}/{self.capacity}):"]
61
+ for key, value in self._store.items():
62
+ val_str = str(value)[:100]
63
+ lines.append(f" - {key}: {val_str}")
64
+ return "\n".join(lines)
nn/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Cognos neural-network subsystem — home of Caudate.
2
+
3
+ Caudate is Cognos's action-selection brain — a small PyTorch transformer
4
+ named for the caudate nucleus, the basal-ganglia structure that learns
5
+ from outcomes to bias which action a real brain takes next. She trains
6
+ on Cognos's own session history to predict, per turn:
7
+
8
+ 1. which tool to call next
9
+ 2. which routing tier (System 1 / System 2) is appropriate
10
+ 3. whether engaging chain-of-thought helps
11
+ 4. an expected reward / value estimate
12
+
13
+ Caudate does NOT replace the LLM cortex. She runs alongside as an
14
+ *advisor*: every inference is logged, and (later) optionally biases the
15
+ agent's prompt. Bad predictions don't break the agent.
16
+
17
+ Module layout:
18
+
19
+ nn/config.py hyperparameters
20
+ nn/encoder.py state encoder (text + tool history + mood)
21
+ nn/caudate.py Caudate — transformer + 4 output heads
22
+ nn/data.py dataset + replay buffer
23
+ nn/trainer.py optimizer / loss / eval / checkpointing
24
+ nn/consolidator.py offline replay → training pairs
25
+ nn/runtime.py inference loader + CaudateAdvisor
26
+
27
+ Persistent state lives at `data/nn/`.
28
+ """
29
+
30
+ from nn.caudate import Caudate, CognosController # alias kept for compat
31
+ from nn.config import NNConfig
32
+ from nn.encoder import StateEncoder
33
+ from nn.runtime import CaudateAdvisor, NNAdvisor, load_advisor
34
+
35
+ __all__ = [
36
+ "NNConfig",
37
+ "Caudate",
38
+ "CognosController",
39
+ "StateEncoder",
40
+ "CaudateAdvisor",
41
+ "NNAdvisor",
42
+ "load_advisor",
43
+ ]
nn/auto_evolve.py ADDED
@@ -0,0 +1,245 @@
1
+ """Auto-evolve — Caudate fires her own NAS runs.
2
+
3
+ Wired into `CaudateObserver._kick_auto_train`. After each background
4
+ training cycle, this module decides whether to launch a NAS run:
5
+
6
+ 1. Was the latest training improvement small? (PlateauScheduler)
7
+ 2. Has the cooldown passed? (default 6h between NAS runs)
8
+ 3. Is there enough VRAM headroom?
9
+ 4. Is no NAS run already in flight?
10
+
11
+ If all four pass, picks a strategy (rotating through evolutionary →
12
+ random → rl), runs the search asynchronously, and if a new champion
13
+ beats the current Caudate by a margin, promotes it and hot-swaps the
14
+ advisor.
15
+
16
+ Every decision (fire / skip / promote / reject) lands in
17
+ `data/nn/nas/auto_log.jsonl` for inspection.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import shutil
26
+ import subprocess
27
+ import time
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ # Strategies cycled through across firings. Evolutionary first because
36
+ # it's most sample-efficient; random as a sanity check; RL once enough
37
+ # trials accumulated to learn from.
38
+ _ROTATION = ["evolutionary", "random", "rl", "evolutionary", "hyperband"]
39
+
40
+
41
+ @dataclass
42
+ class AutoEvolveConfig:
43
+ enabled: bool = True
44
+ min_vram_gb: float = 3.0 # skip NAS if less than this is free
45
+ cooldown_seconds: float = 6 * 3600 # don't fire more often than this
46
+ n_trials_per_run: int = 8
47
+ train_steps_per_trial: int = 150
48
+ promote_margin: float = 0.005 # new champion must beat by this much
49
+ rotation: list[str] = None # strategies to cycle through
50
+
51
+
52
+ def _free_vram_gb() -> float:
53
+ try:
54
+ out = subprocess.run(
55
+ ["nvidia-smi", "--query-gpu=memory.free",
56
+ "--format=csv,noheader,nounits"],
57
+ capture_output=True, text=True, timeout=2,
58
+ ).stdout.strip().splitlines()[0]
59
+ return float(out) / 1024.0
60
+ except Exception:
61
+ return float("inf") # if we can't check, assume OK
62
+
63
+
64
+ class AutoEvolver:
65
+ """Decides + drives autonomous NAS runs from the observer."""
66
+
67
+ def __init__(self, observer: Any, cfg: AutoEvolveConfig | None = None):
68
+ self.observer = observer
69
+ self.cfg = cfg or AutoEvolveConfig()
70
+ if self.cfg.rotation is None:
71
+ self.cfg.rotation = list(_ROTATION)
72
+ self._nas_in_flight: asyncio.Task | None = None
73
+ self._n_fires = 0
74
+ self._last_fire_at: float = 0.0
75
+ self._auto_log = Path(self.observer.cfg.data_dir) / "nas" / "auto_log.jsonl"
76
+ self._auto_log.parent.mkdir(parents=True, exist_ok=True)
77
+
78
+ # ------------------------------------------------------------------
79
+
80
+ def maybe_fire(self) -> None:
81
+ """Called from CaudateObserver after each auto-train cycle."""
82
+ if not self.cfg.enabled:
83
+ return
84
+ # Owner killswitch — overrides everything else.
85
+ try:
86
+ from core.ownership import caudate_may_act
87
+ if not caudate_may_act():
88
+ self._log("skip", reason="owner_killswitch_active")
89
+ return
90
+ except Exception:
91
+ pass
92
+ if self._nas_in_flight is not None and not self._nas_in_flight.done():
93
+ self._log("skip", reason="already_in_flight")
94
+ return
95
+ if time.time() - self._last_fire_at < self.cfg.cooldown_seconds:
96
+ self._log("skip", reason="cooldown")
97
+ return
98
+
99
+ # Plateau check via the existing scheduler
100
+ try:
101
+ from nn.nas.scheduler import PlateauScheduler
102
+ sched = PlateauScheduler()
103
+ if not sched.should_fire():
104
+ self._log("skip", reason="not_plateaued",
105
+ stalled_for=sched.status().get("stalled_for"))
106
+ return
107
+ except Exception as e:
108
+ logger.debug(f"plateau check failed: {e}")
109
+ return
110
+
111
+ # VRAM check
112
+ free_gb = _free_vram_gb()
113
+ if free_gb < self.cfg.min_vram_gb:
114
+ self._log("skip", reason="vram_low", free_gb=round(free_gb, 1))
115
+ return
116
+
117
+ # All gates passed — schedule a background NAS run.
118
+ try:
119
+ loop = asyncio.get_running_loop()
120
+ except RuntimeError:
121
+ self._log("skip", reason="no_running_loop")
122
+ return
123
+
124
+ strategy = self.cfg.rotation[self._n_fires % len(self.cfg.rotation)]
125
+ self._n_fires += 1
126
+ self._last_fire_at = time.time()
127
+ self._log("fire", strategy=strategy, n_fire=self._n_fires,
128
+ free_vram_gb=round(free_gb, 1))
129
+ self._nas_in_flight = loop.create_task(
130
+ self._run_async(strategy)
131
+ )
132
+
133
+ # ------------------------------------------------------------------
134
+
135
+ async def _run_async(self, strategy_name: str) -> None:
136
+ """Run NAS in a thread (heavy CPU/GPU work) and post-process."""
137
+ try:
138
+ result = await asyncio.to_thread(self._run_sync, strategy_name)
139
+ self._log("complete", strategy=strategy_name, result=result)
140
+ # Hot-swap the advisor in case champion was promoted
141
+ self.observer.reload_advisor()
142
+ try:
143
+ from nn.nas.scheduler import PlateauScheduler
144
+ PlateauScheduler().mark_fired()
145
+ except Exception:
146
+ pass
147
+ except Exception as e:
148
+ self._log("error", strategy=strategy_name, error=str(e))
149
+ logger.warning(f"auto-NAS [{strategy_name}] failed: {e}")
150
+
151
+ def _run_sync(self, strategy_name: str) -> dict[str, Any]:
152
+ from nn.nas import Searcher
153
+ from nn.nas.searcher import SearcherConfig
154
+ from nn.nas.strategies import make_strategy
155
+ from nn.nas.strategies.base import StrategyConfig
156
+
157
+ scfg = StrategyConfig(
158
+ n_trials=self.cfg.n_trials_per_run,
159
+ train_steps_per_trial=self.cfg.train_steps_per_trial,
160
+ seed=int(time.time()) % 10000,
161
+ )
162
+ if strategy_name == "hyperband":
163
+ base = make_strategy("random", config=scfg)
164
+ strategy = make_strategy("hyperband", config=scfg, base=base)
165
+ elif strategy_name == "darts":
166
+ strategy = make_strategy("darts", config=scfg, epochs=3, n_layers=4)
167
+ else:
168
+ strategy = make_strategy(strategy_name, config=scfg)
169
+
170
+ searcher = Searcher(
171
+ cfg=SearcherConfig(
172
+ n_trials=self.cfg.n_trials_per_run,
173
+ train_steps_per_trial=self.cfg.train_steps_per_trial,
174
+ seed=scfg.seed,
175
+ cost_cap=8.0,
176
+ min_corpus_size=64,
177
+ promote_margin=self.cfg.promote_margin,
178
+ ),
179
+ strategy=strategy,
180
+ )
181
+ summary = searcher.search()
182
+
183
+ # If a new champion exists, promote it over the current Caudate
184
+ if summary.get("status") == "complete" and summary.get("champion"):
185
+ self._maybe_promote_to_main()
186
+
187
+ return {
188
+ "status": summary.get("status"),
189
+ "n_trials": summary.get("n_trials"),
190
+ "champion": summary.get("champion"),
191
+ }
192
+
193
+ def _maybe_promote_to_main(self) -> None:
194
+ """Copy NAS champion → main caudate.pt if it beats current."""
195
+ try:
196
+ from nn.nas.store import NASStore
197
+ store = NASStore()
198
+ champ = store.champion()
199
+ if not champ:
200
+ return
201
+ # Get champion fitness
202
+ champ_fit = float(champ.get("result", {}).get("fitness", 0.0))
203
+ # We promote unconditionally — Searcher already gated on the
204
+ # margin against the previous champion. The Searcher's own
205
+ # save_champion step already wrote champion.pt. Now copy to
206
+ # the main slot so the live observer picks it up.
207
+ ok = store.promote_to_main(self.observer.cfg.checkpoint_path)
208
+ if ok:
209
+ logger.info(
210
+ f"auto-NAS promoted champion → main caudate "
211
+ f"(fitness={champ_fit:.3f})"
212
+ )
213
+ self._log("promote", fitness=champ_fit)
214
+ except Exception as e:
215
+ logger.debug(f"auto-NAS promotion failed: {e}")
216
+
217
+ # ------------------------------------------------------------------
218
+
219
+ def status(self) -> dict[str, Any]:
220
+ return {
221
+ "enabled": self.cfg.enabled,
222
+ "n_fires": self._n_fires,
223
+ "in_flight": (
224
+ self._nas_in_flight is not None
225
+ and not self._nas_in_flight.done()
226
+ ),
227
+ "seconds_since_last_fire": (
228
+ int(time.time() - self._last_fire_at)
229
+ if self._last_fire_at else None
230
+ ),
231
+ "cooldown_seconds": self.cfg.cooldown_seconds,
232
+ "min_vram_gb": self.cfg.min_vram_gb,
233
+ "rotation": list(self.cfg.rotation),
234
+ }
235
+
236
+ def _log(self, action: str, **kwargs: Any) -> None:
237
+ try:
238
+ with self._auto_log.open("a") as f:
239
+ f.write(json.dumps({
240
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
241
+ "action": action,
242
+ **kwargs,
243
+ }) + "\n")
244
+ except Exception as e:
245
+ logger.debug(f"auto_log write failed: {e}")