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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- 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
|