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
nn/policy.py ADDED
@@ -0,0 +1,214 @@
1
+ """Graduation policy — when does Caudate get to influence the agent?
2
+
3
+ Five trust levels. Promotion needs both *enough samples* and
4
+ *good-enough accuracy*; demotion is symmetric so a regression after a
5
+ bad retrain doesn't keep her in a level she no longer earns.
6
+
7
+ SILENT no checkpoint loaded no predictions
8
+ OBSERVER checkpoint loaded predicts but ignored
9
+ WHISPER accuracy ≥ 0.50, ≥ 50 predictions prediction → prompt hint
10
+ ADVISOR accuracy ≥ 0.70, ≥ 200 predictions prediction → constrained tool list (ADR 0007)
11
+ CONTROLLER accuracy ≥ 0.80, ≥ 500 predictions prediction → constrained list + thinking gate
12
+
13
+ The accuracy used is `composite()` from the scorer — a weighted blend
14
+ of tool / tier / think accuracy, matching the training loss weights.
15
+
16
+ Persistence: the *current* level is saved alongside the scorecard so a
17
+ restart doesn't reset her to OBSERVER. Re-promotion still requires the
18
+ sample/accuracy gates so a forced demotion (e.g. via /caudate demote)
19
+ holds until earned back.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ from dataclasses import dataclass
27
+ from enum import IntEnum
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class TrustLevel(IntEnum):
35
+ SILENT = 0
36
+ OBSERVER = 1
37
+ WHISPER = 2
38
+ ADVISOR = 3
39
+ CONTROLLER = 4
40
+
41
+ @property
42
+ def label(self) -> str:
43
+ return self.name.lower()
44
+
45
+
46
+ @dataclass
47
+ class _Gate:
48
+ level: TrustLevel
49
+ min_samples: int
50
+ min_accuracy: float
51
+
52
+
53
+ # Promotion thresholds. Demotion uses the previous gate's threshold
54
+ # minus a small hysteresis to avoid flapping.
55
+ _GATES: list[_Gate] = [
56
+ _Gate(TrustLevel.OBSERVER, min_samples=0, min_accuracy=0.0),
57
+ _Gate(TrustLevel.WHISPER, min_samples=50, min_accuracy=0.50),
58
+ _Gate(TrustLevel.ADVISOR, min_samples=200, min_accuracy=0.70),
59
+ _Gate(TrustLevel.CONTROLLER, min_samples=500, min_accuracy=0.80),
60
+ ]
61
+ _HYSTERESIS = 0.05 # accuracy must drop this much below a gate to demote
62
+
63
+
64
+ class GraduationPolicy:
65
+ """Owns the current trust level. Reads scorer + advisor state to update."""
66
+
67
+ def __init__(self, state_path: Path | str = "data/nn/policy.json"):
68
+ self.state_path = Path(state_path)
69
+ self.state_path.parent.mkdir(parents=True, exist_ok=True)
70
+ self._level: TrustLevel = TrustLevel.SILENT
71
+ self._frozen: bool = False # /caudate freeze pins the level
72
+ self._load()
73
+
74
+ # ------------------------------------------------------------------
75
+
76
+ @property
77
+ def level(self) -> TrustLevel:
78
+ return self._level
79
+
80
+ @property
81
+ def frozen(self) -> bool:
82
+ return self._frozen
83
+
84
+ def can_predict(self) -> bool:
85
+ return self._level >= TrustLevel.OBSERVER
86
+
87
+ def can_whisper(self) -> bool:
88
+ return self._level >= TrustLevel.WHISPER
89
+
90
+ def can_advise(self) -> bool:
91
+ return self._level >= TrustLevel.ADVISOR
92
+
93
+ def can_control(self) -> bool:
94
+ return self._level >= TrustLevel.CONTROLLER
95
+
96
+ # ------------------------------------------------------------------
97
+ # Updates
98
+ # ------------------------------------------------------------------
99
+
100
+ def update(self, samples: int, composite_acc: float, advisor_loaded: bool) -> TrustLevel:
101
+ """Recompute the trust level from observed performance.
102
+
103
+ Called by the observer after every turn. No-op if frozen.
104
+ """
105
+ if self._frozen:
106
+ return self._level
107
+
108
+ if not advisor_loaded:
109
+ new_level = TrustLevel.SILENT
110
+ else:
111
+ new_level = self._level_for(samples, composite_acc, current=self._level)
112
+
113
+ if new_level != self._level:
114
+ old = self._level
115
+ self._level = new_level
116
+ self._save()
117
+ arrow = "↑" if new_level > old else "↓"
118
+ logger.info(
119
+ f"Caudate trust level {arrow} {old.label} → {new_level.label} "
120
+ f"(samples={samples}, composite={composite_acc:.3f})"
121
+ )
122
+ return self._level
123
+
124
+ def freeze(self, level: TrustLevel | None = None) -> None:
125
+ """Pin the level (useful for testing or rolling back a bad retrain)."""
126
+ if level is not None:
127
+ self._level = level
128
+ self._frozen = True
129
+ self._save()
130
+
131
+ def thaw(self) -> None:
132
+ self._frozen = False
133
+ self._save()
134
+
135
+ def force(self, level: TrustLevel) -> None:
136
+ """Manually set the level. Doesn't freeze — next update can change it."""
137
+ self._level = level
138
+ self._save()
139
+
140
+ # ------------------------------------------------------------------
141
+ # Inspection
142
+ # ------------------------------------------------------------------
143
+
144
+ def next_gate(self) -> _Gate | None:
145
+ """The next promotion gate (or None if already at top)."""
146
+ for gate in _GATES:
147
+ if gate.level > self._level:
148
+ return gate
149
+ return None
150
+
151
+ def progress_to_next(self, samples: int, composite_acc: float) -> dict[str, Any]:
152
+ """How close is she to the next promotion?"""
153
+ gate = self.next_gate()
154
+ if gate is None:
155
+ return {"at_top": True}
156
+ return {
157
+ "next_level": gate.level.label,
158
+ "samples_needed": max(0, gate.min_samples - samples),
159
+ "current_samples": samples,
160
+ "samples_progress": min(1.0, samples / max(1, gate.min_samples)),
161
+ "accuracy_needed": gate.min_accuracy,
162
+ "current_accuracy": composite_acc,
163
+ "accuracy_progress": min(1.0, composite_acc / max(1e-8, gate.min_accuracy)),
164
+ }
165
+
166
+ def report(self, samples: int, composite_acc: float) -> dict[str, Any]:
167
+ return {
168
+ "level": self._level.label,
169
+ "level_int": int(self._level),
170
+ "frozen": self._frozen,
171
+ "next": self.progress_to_next(samples, composite_acc),
172
+ }
173
+
174
+ # ------------------------------------------------------------------
175
+ # Internals
176
+ # ------------------------------------------------------------------
177
+
178
+ def _level_for(
179
+ self, samples: int, composite_acc: float, current: TrustLevel,
180
+ ) -> TrustLevel:
181
+ # Promote: highest gate she clears
182
+ target = TrustLevel.OBSERVER
183
+ for gate in _GATES:
184
+ if samples >= gate.min_samples and composite_acc >= gate.min_accuracy:
185
+ target = gate.level
186
+
187
+ # Demote with hysteresis: she only loses the current level if
188
+ # accuracy has fallen *below the entry threshold minus hysteresis*.
189
+ if target < current:
190
+ current_gate = next((g for g in _GATES if g.level == current), None)
191
+ if current_gate is not None:
192
+ if (composite_acc >= current_gate.min_accuracy - _HYSTERESIS
193
+ and samples >= current_gate.min_samples):
194
+ target = current
195
+ return target
196
+
197
+ def _load(self) -> None:
198
+ if not self.state_path.exists():
199
+ return
200
+ try:
201
+ data = json.loads(self.state_path.read_text())
202
+ self._level = TrustLevel(int(data.get("level", 0)))
203
+ self._frozen = bool(data.get("frozen", False))
204
+ except Exception as e:
205
+ logger.debug(f"policy load failed: {e}")
206
+
207
+ def _save(self) -> None:
208
+ try:
209
+ self.state_path.write_text(json.dumps({
210
+ "level": int(self._level),
211
+ "frozen": self._frozen,
212
+ }, indent=2))
213
+ except Exception as e:
214
+ logger.debug(f"policy save failed: {e}")
nn/runtime.py ADDED
@@ -0,0 +1,343 @@
1
+ """Runtime advisor — load Caudate's weights, expose `predict()` to the agent.
2
+
3
+ The advisor is **non-destructive**. The agent doesn't *have* to follow
4
+ Caudate's predictions. Phase 1 just logs every prediction for
5
+ measurement. Future phases can:
6
+
7
+ - bias the LLM's tool prompt with predicted-tool hints
8
+ - skip the heuristic router and use predicted tier directly
9
+ - flip thinking on when predicted helpful
10
+
11
+ Each prediction lands in `data/nn/predictions.jsonl` so we can later
12
+ correlate (predicted, actual, observed reward) and decide whether
13
+ Caudate is good enough to graduate from advisor to controller.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import torch
26
+
27
+ from nn.caudate import Caudate
28
+ from nn.config import NNConfig
29
+ from nn.data import (
30
+ ConversationSample, ReplayBuffer, SourceVocab, ToolVocab, collate,
31
+ )
32
+ from nn.format import ChatMessage, ToolCall, ToolDef
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ @dataclass
38
+ class Prediction:
39
+ tool: str
40
+ tool_confidence: float
41
+ tier: str # "fast" | "slow"
42
+ tier_confidence: float
43
+ think: float # in [0, 1]
44
+ value: float # in [0, 1]
45
+ # Top-K candidates (name, probability) sorted high → low. Includes
46
+ # the predicted tool itself at position 0. Drives ADR-0007 constrained
47
+ # routing: at ADVISOR+, the LLM's tool list is pruned to this set.
48
+ # The synthetic "<no_tool>" slot is excluded — pruning only acts on
49
+ # real-tool predictions per ADR-0007's "soft suggest" decision.
50
+ tool_top_k: list[tuple[str, float]] = field(default_factory=list)
51
+
52
+
53
+ class CaudateAdvisor:
54
+ """Inference-only wrapper around a trained Caudate."""
55
+
56
+ def __init__(
57
+ self,
58
+ cfg: NNConfig,
59
+ model: Caudate,
60
+ vocab: ToolVocab,
61
+ source_vocab: SourceVocab | None = None,
62
+ ):
63
+ self.cfg = cfg
64
+ self.model = model.eval()
65
+ self.vocab = vocab
66
+ self.source_vocab = source_vocab or SourceVocab(
67
+ cap=getattr(cfg, "source_vocab_size", SourceVocab.DEFAULT_CAP)
68
+ )
69
+ self.device = next(model.parameters()).device
70
+ # Reverse lookup over the bounded n_tools_out range. Multiple
71
+ # vocab ids may collide on the same head index because
72
+ # n_tools_out < vocab size in general; the reverse map keeps the
73
+ # first one seen, which is fine for explanations.
74
+ self._head_idx_to_name: dict[int, str] = {}
75
+ for name, idx in vocab.to_dict().items():
76
+ slot = idx % cfg.n_tools_out
77
+ self._head_idx_to_name.setdefault(slot, name)
78
+
79
+ # ------------------------------------------------------------------
80
+
81
+ @torch.no_grad()
82
+ def predict(
83
+ self,
84
+ messages: list[str],
85
+ tool_history: list[str],
86
+ mood: list[float],
87
+ image_paths: list[str] | None = None,
88
+ model_source: str = "<unknown>",
89
+ available_tools: list[ToolDef] | None = None,
90
+ ) -> Prediction:
91
+ """Run inference. Optional ``available_tools`` enables the
92
+ contrastive tool head: pass the list of ToolDef the assistant
93
+ can call right now and the head scores each one. When None or
94
+ empty, the tool head sits out (no candidates → no prediction)
95
+ and the returned Prediction has ``tool="<no_tool>"`` with
96
+ zero confidence.
97
+
98
+ ``messages`` are role-prefixed strings ("user: hi"); they're
99
+ parsed back into ChatMessages internally. ``tool_history`` is
100
+ the rolling list of tool names called in recent turns —
101
+ becomes a synthetic assistant message at the end of the
102
+ prefix so the encoder picks it up via the tool-history
103
+ channel."""
104
+ # Parse role-prefixed strings → ChatMessages
105
+ conv: list[ChatMessage] = []
106
+ for m in messages:
107
+ if not m:
108
+ continue
109
+ role, sep, content = m.partition(": ")
110
+ if not sep:
111
+ role, content = "user", m
112
+ conv.append(ChatMessage(role=role, content=content))
113
+ if tool_history:
114
+ conv.append(ChatMessage(
115
+ role="assistant",
116
+ content="",
117
+ tool_calls=[ToolCall(name=t) for t in tool_history if t],
118
+ ))
119
+
120
+ sample = ConversationSample(
121
+ conversation=conv,
122
+ tools=list(available_tools or []),
123
+ mood=mood,
124
+ image_paths=image_paths or [],
125
+ target_tool="<unk>", target_tier=0, target_think=0.0,
126
+ target_value=0.0,
127
+ model_source=model_source,
128
+ )
129
+ batch = collate(
130
+ [sample], self.cfg, self.vocab, source_vocab=self.source_vocab,
131
+ )
132
+ out = self.model(
133
+ messages=batch["messages"],
134
+ tool_ids=batch["tool_ids"].to(self.device),
135
+ mood=batch["mood"].to(self.device),
136
+ image_paths=batch.get("image_paths"),
137
+ source_id=batch["source_id"].to(self.device),
138
+ tool_specs=batch.get("tool_specs"),
139
+ )
140
+
141
+ # Contrastive tool head — only meaningful when candidates were
142
+ # supplied. The first candidate slot is always <no_tool>, so a
143
+ # prediction of "answer directly" is a normal class.
144
+ tool_top_k: list[tuple[str, float]] = []
145
+ if "tool_logits" in out:
146
+ tool_probs = torch.softmax(out["tool_logits"], dim=-1)[0]
147
+ tool_idx = int(tool_probs.argmax().item())
148
+ tool_conf = float(tool_probs[tool_idx].item())
149
+ # Slot 0 is the synthetic <no_tool>; the rest are the
150
+ # caller's available_tools in supplied order.
151
+ if tool_idx == 0:
152
+ tool_name = "<no_tool>"
153
+ elif available_tools and (tool_idx - 1) < len(available_tools):
154
+ tool_name = available_tools[tool_idx - 1].name
155
+ else:
156
+ tool_name = "<unk>"
157
+
158
+ # Top-K real-tool candidates for ADR-0007 constrained routing.
159
+ # Skip slot 0 (<no_tool>) — pruning acts on real tools only.
160
+ k = min(self.cfg.advisor_top_k, tool_probs.shape[0] - 1)
161
+ if available_tools and k > 0:
162
+ # Indices 1..N map to available_tools[0..N-1]. Score those
163
+ # slots only, then sort desc.
164
+ n_real = min(len(available_tools), tool_probs.shape[0] - 1)
165
+ scored = [
166
+ (available_tools[i].name, float(tool_probs[i + 1].item()))
167
+ for i in range(n_real)
168
+ ]
169
+ scored.sort(key=lambda x: -x[1])
170
+ tool_top_k = scored[:k]
171
+ else:
172
+ tool_name = "<no_tool>"
173
+ tool_conf = 0.0
174
+
175
+ tier_probs = torch.softmax(out["tier_logits"], dim=-1)[0]
176
+ tier_idx = int(tier_probs.argmax().item())
177
+ tier_conf = float(tier_probs[tier_idx].item())
178
+ tier_name = ("fast", "slow")[tier_idx]
179
+
180
+ think = float(torch.sigmoid(out["think_logit"])[0].item())
181
+ value = float(torch.sigmoid(out["value"])[0].item())
182
+
183
+ pred = Prediction(
184
+ tool=tool_name, tool_confidence=tool_conf,
185
+ tier=tier_name, tier_confidence=tier_conf,
186
+ think=think, value=value,
187
+ tool_top_k=tool_top_k,
188
+ )
189
+ self._log(pred)
190
+ return pred
191
+
192
+ # ------------------------------------------------------------------
193
+ # ADR-0006 Phase A — feature-success prediction surface
194
+ # ------------------------------------------------------------------
195
+
196
+ @torch.no_grad()
197
+ def predict_feature_success(
198
+ self,
199
+ feature_text: str,
200
+ model_id: str | None = None,
201
+ ) -> dict[str, Any] | None:
202
+ """Return P(feature succeeds | text, model_source) using the
203
+ feature_success head if present, else None.
204
+
205
+ Returns None — not a fake prediction — when the loaded checkpoint
206
+ was trained before the head existed (so its weights weren't
207
+ learned and the output would be noise). The forge_advisor falls
208
+ back to the heuristic baseline in that case."""
209
+ # The head was added in nn/heads.py:EXTENDED_HEADS. A checkpoint
210
+ # trained before this addition will have the module (heads are
211
+ # constructed from the spec list, not the state-dict) but its
212
+ # weights will be at Xavier init, not learned. Distinguish by
213
+ # checking whether the head's weights look trained — a very
214
+ # cheap proxy is whether the bias on the last linear is non-zero
215
+ # (Xavier init zeros the bias; even a few training steps move
216
+ # it). When indistinguishable, the caller treats None as
217
+ # "use baseline".
218
+ head_mod = getattr(self.model.heads, "feature_success", None)
219
+ if head_mod is None:
220
+ return None
221
+ last_linear = head_mod[-1] if hasattr(head_mod, "__len__") else None
222
+ if last_linear is not None and getattr(last_linear, "bias", None) is not None:
223
+ if torch.allclose(last_linear.bias, torch.zeros_like(last_linear.bias)):
224
+ # Looks like un-trained init. Return None so the caller
225
+ # falls back to the heuristic.
226
+ return None
227
+
228
+ sample = ConversationSample(
229
+ conversation=[ChatMessage(role="user", content=feature_text or "")],
230
+ tools=[],
231
+ mood=[0.5, 0.5, 0.5, 0.5],
232
+ image_paths=[],
233
+ target_tool="<unk>", target_tier=0, target_think=0.0,
234
+ target_value=0.0,
235
+ model_source=model_id or "<unknown>",
236
+ )
237
+ batch = collate(
238
+ [sample], self.cfg, self.vocab, source_vocab=self.source_vocab,
239
+ )
240
+ out = self.model(
241
+ messages=batch["messages"],
242
+ tool_ids=batch["tool_ids"].to(self.device),
243
+ mood=batch["mood"].to(self.device),
244
+ image_paths=batch.get("image_paths"),
245
+ source_id=batch["source_id"].to(self.device),
246
+ )
247
+ logit = out.get("feature_success_logit")
248
+ if logit is None:
249
+ return None
250
+ # (B,) tensor → scalar
251
+ p = float(torch.sigmoid(logit.detach())[0].item())
252
+ return {
253
+ "success_prob": max(0.0, min(1.0, p)),
254
+ "reason": f"caudate head · model={model_id or '<unknown>'}",
255
+ }
256
+
257
+ # ------------------------------------------------------------------
258
+
259
+ def _log(self, pred: Prediction) -> None:
260
+ try:
261
+ path = Path(self.cfg.advisor_log_path)
262
+ path.parent.mkdir(parents=True, exist_ok=True)
263
+ with path.open("a") as f:
264
+ f.write(json.dumps({
265
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
266
+ "tool": pred.tool, "tool_conf": round(pred.tool_confidence, 4),
267
+ "tier": pred.tier, "tier_conf": round(pred.tier_confidence, 4),
268
+ "think": round(pred.think, 4),
269
+ "value": round(pred.value, 4),
270
+ }) + "\n")
271
+ except Exception as e:
272
+ logger.debug(f"advisor log write failed: {e}")
273
+
274
+
275
+ def load_advisor(cfg: NNConfig | None = None) -> CaudateAdvisor | None:
276
+ """Load Caudate's checkpoint; return None if absent or mismatched.
277
+
278
+ Safe to call at every agent startup — failure is silent so the
279
+ agent works fine even before any training has happened.
280
+
281
+ Architecture detection: if a `caudate.meta.json` sidecar exists
282
+ next to the checkpoint, its `config` block overrides the default
283
+ architecture knobs (d_model, n_layers, n_heads, d_ff). This lets a
284
+ retrained checkpoint at a different scale (e.g. Phase-5 20M / 95M)
285
+ load cleanly without the loader hard-coding the old shape.
286
+ """
287
+ cfg = cfg or NNConfig()
288
+ ckpt_path = Path(cfg.checkpoint_path)
289
+ if not ckpt_path.exists():
290
+ return None
291
+ try:
292
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
293
+ except Exception as e:
294
+ logger.warning(f"Caudate checkpoint load failed: {e}")
295
+ return None
296
+
297
+ # Prefer meta-file config over default — handles scaled retrains.
298
+ meta_path = Path(getattr(cfg, "metadata_path", "data/nn/caudate.meta.json"))
299
+ if meta_path.exists():
300
+ try:
301
+ import json
302
+ meta = json.loads(meta_path.read_text())
303
+ arch = meta.get("config") or {}
304
+ arch_keys = ("d_model", "n_heads", "n_layers", "d_ff", "dropout",
305
+ "n_tools_out", "source_vocab_size", "tool_vocab_size",
306
+ "tool_embed_dim", "mood_dim", "history_window",
307
+ "msg_window", "image_window", "use_vision",
308
+ "vision_backend", "vision_embed_dim", "vision_dtype",
309
+ "vision_encoder_name", "text_embed_dim")
310
+ overrides = {k: arch[k] for k in arch_keys if k in arch}
311
+ if overrides:
312
+ cfg = cfg.model_copy(update=overrides)
313
+ logger.info(
314
+ f"Caudate loading with meta-overridden arch: "
315
+ f"d_model={cfg.d_model} n_layers={cfg.n_layers}"
316
+ )
317
+ except Exception as e:
318
+ logger.warning(f"meta override failed, using defaults: {e}")
319
+
320
+ try:
321
+ vocab = ToolVocab.from_dict(ckpt.get("vocab", {}))
322
+ source_vocab = SourceVocab.from_dict(
323
+ ckpt.get("source_vocab", {}),
324
+ cap=getattr(cfg, "source_vocab_size", SourceVocab.DEFAULT_CAP),
325
+ )
326
+ model = Caudate(cfg)
327
+ # strict=False — pre-Phase-2 checkpoints have no source_embed
328
+ # weights; the Caudate constructor zero-inits them so the
329
+ # baseline behaviour is preserved until the next retrain.
330
+ missing, unexpected = model.load_state_dict(ckpt["model"], strict=False)
331
+ if missing or unexpected:
332
+ logger.info(
333
+ f"advisor checkpoint loaded non-strict "
334
+ f"missing={list(missing)[:5]} unexpected={list(unexpected)[:5]}"
335
+ )
336
+ except Exception as e:
337
+ logger.warning(f"Caudate model rebuild failed: {e}")
338
+ return None
339
+ return CaudateAdvisor(cfg=cfg, model=model, vocab=vocab, source_vocab=source_vocab)
340
+
341
+
342
+ # Backward-compat alias — old code that imported NNAdvisor still works.
343
+ NNAdvisor = CaudateAdvisor