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/format.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Standard chat-conversation schema for Caudate.
|
|
2
|
+
|
|
3
|
+
The on-disk and in-memory training format mirrors the OpenAI tool-call
|
|
4
|
+
JSON shape that every public tool-use dataset uses (xLAM, ToolBench,
|
|
5
|
+
ToolAlpaca, Gorilla, API-Bank). This lets Caudate train directly on
|
|
6
|
+
external corpora without an adapter step.
|
|
7
|
+
|
|
8
|
+
Schema (one training example):
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"conversations": [
|
|
12
|
+
{"role": "system" | "user" | "assistant" | "tool",
|
|
13
|
+
"content": str,
|
|
14
|
+
"tool_calls"?: [
|
|
15
|
+
{"type": "function",
|
|
16
|
+
"function": {"name": str, "arguments": str (JSON)}}
|
|
17
|
+
],
|
|
18
|
+
"name"?: str, # required when role == "tool"
|
|
19
|
+
"tool_call_id"?: str}, # links a tool result back to the call
|
|
20
|
+
...
|
|
21
|
+
],
|
|
22
|
+
"tools": [
|
|
23
|
+
{"type": "function",
|
|
24
|
+
"function": {"name": str, "description": str,
|
|
25
|
+
"parameters": dict (JSON schema)}}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
These dataclasses are deliberately thin — no validation beyond what
|
|
30
|
+
parsing needs. The trainer treats malformed rows as skip-and-continue
|
|
31
|
+
rather than raising, because external datasets are noisy.
|
|
32
|
+
|
|
33
|
+
Cognos-specific signals (mood, the 14 optional heads, model_source,
|
|
34
|
+
images) live on ConversationSample in nn/data.py, NOT in this schema —
|
|
35
|
+
that's the boundary that keeps external data ingestible.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ─── Tool call (assistant → tool) ─────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ToolCall:
|
|
50
|
+
"""One function call inside an assistant message."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
arguments: str = "" # JSON-encoded string per OpenAI spec
|
|
54
|
+
id: str | None = None # tool_call_id; optional in some datasets
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, d: dict[str, Any]) -> "ToolCall":
|
|
58
|
+
# OpenAI shape: {"type": "function", "function": {"name", "arguments"}}
|
|
59
|
+
# Some datasets flatten: {"name", "arguments"}. Accept both.
|
|
60
|
+
fn = d.get("function") if isinstance(d.get("function"), dict) else d
|
|
61
|
+
args = fn.get("arguments", "")
|
|
62
|
+
# Arguments can be a dict in some datasets — normalise to JSON string
|
|
63
|
+
# so downstream consumers don't have to handle both shapes.
|
|
64
|
+
if isinstance(args, dict):
|
|
65
|
+
args = json.dumps(args)
|
|
66
|
+
return cls(
|
|
67
|
+
name=str(fn.get("name") or ""),
|
|
68
|
+
arguments=str(args or ""),
|
|
69
|
+
id=d.get("id") or d.get("tool_call_id"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> dict[str, Any]:
|
|
73
|
+
out: dict[str, Any] = {
|
|
74
|
+
"type": "function",
|
|
75
|
+
"function": {"name": self.name, "arguments": self.arguments},
|
|
76
|
+
}
|
|
77
|
+
if self.id is not None:
|
|
78
|
+
out["id"] = self.id
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ─── Chat message ─────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ChatMessage:
|
|
87
|
+
"""One conversation turn. Roles: system | user | assistant | tool."""
|
|
88
|
+
|
|
89
|
+
role: str
|
|
90
|
+
content: str = ""
|
|
91
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
92
|
+
name: str | None = None # tool name when role == "tool"
|
|
93
|
+
tool_call_id: str | None = None # links tool result to the call
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_dict(cls, d: dict[str, Any]) -> "ChatMessage":
|
|
97
|
+
# `content` is sometimes a list of content-blocks (OpenAI vision
|
|
98
|
+
# shape). Flatten to text — Caudate's encoder doesn't currently
|
|
99
|
+
# consume content-block structure, only the text. Vision still
|
|
100
|
+
# works through image_paths on ConversationSample.
|
|
101
|
+
content = d.get("content")
|
|
102
|
+
if isinstance(content, list):
|
|
103
|
+
chunks: list[str] = []
|
|
104
|
+
for blk in content:
|
|
105
|
+
if isinstance(blk, dict):
|
|
106
|
+
txt = blk.get("text") or blk.get("content") or ""
|
|
107
|
+
if txt:
|
|
108
|
+
chunks.append(str(txt))
|
|
109
|
+
elif isinstance(blk, str):
|
|
110
|
+
chunks.append(blk)
|
|
111
|
+
content = " ".join(chunks)
|
|
112
|
+
elif content is None:
|
|
113
|
+
content = ""
|
|
114
|
+
raw_calls = d.get("tool_calls") or []
|
|
115
|
+
calls = [ToolCall.from_dict(c) for c in raw_calls if isinstance(c, dict)]
|
|
116
|
+
return cls(
|
|
117
|
+
role=str(d.get("role") or ""),
|
|
118
|
+
content=str(content),
|
|
119
|
+
tool_calls=calls,
|
|
120
|
+
name=d.get("name"),
|
|
121
|
+
tool_call_id=d.get("tool_call_id"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def to_dict(self) -> dict[str, Any]:
|
|
125
|
+
out: dict[str, Any] = {"role": self.role, "content": self.content}
|
|
126
|
+
if self.tool_calls:
|
|
127
|
+
out["tool_calls"] = [tc.to_dict() for tc in self.tool_calls]
|
|
128
|
+
if self.name is not None:
|
|
129
|
+
out["name"] = self.name
|
|
130
|
+
if self.tool_call_id is not None:
|
|
131
|
+
out["tool_call_id"] = self.tool_call_id
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ─── Tool definition (what's available for the assistant to call) ─────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class ToolDef:
|
|
140
|
+
"""One tool entry in the `tools` field of a training example."""
|
|
141
|
+
|
|
142
|
+
name: str
|
|
143
|
+
description: str = ""
|
|
144
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_dict(cls, d: dict[str, Any]) -> "ToolDef":
|
|
148
|
+
fn = d.get("function") if isinstance(d.get("function"), dict) else d
|
|
149
|
+
params = fn.get("parameters") or {}
|
|
150
|
+
if not isinstance(params, dict):
|
|
151
|
+
params = {}
|
|
152
|
+
return cls(
|
|
153
|
+
name=str(fn.get("name") or ""),
|
|
154
|
+
description=str(fn.get("description") or ""),
|
|
155
|
+
parameters=params,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> dict[str, Any]:
|
|
159
|
+
return {
|
|
160
|
+
"type": "function",
|
|
161
|
+
"function": {
|
|
162
|
+
"name": self.name,
|
|
163
|
+
"description": self.description,
|
|
164
|
+
"parameters": self.parameters,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ─── Conversation (one training example, schema-side only) ───────────
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class Conversation:
|
|
174
|
+
"""A single training example in standard chat-tool-call shape.
|
|
175
|
+
|
|
176
|
+
This is the disk/serialization view. The training-time view —
|
|
177
|
+
Conversation plus Cognos-specific signals (mood, model_source,
|
|
178
|
+
image_paths, optional head targets) — is ConversationSample in
|
|
179
|
+
nn/data.py."""
|
|
180
|
+
|
|
181
|
+
messages: list[ChatMessage] = field(default_factory=list)
|
|
182
|
+
tools: list[ToolDef] = field(default_factory=list)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_dict(cls, d: dict[str, Any]) -> "Conversation":
|
|
186
|
+
# Accept both "messages" (modern) and "conversations" (some legacy
|
|
187
|
+
# datasets) as the field name for the turn list.
|
|
188
|
+
raw_msgs = d.get("messages")
|
|
189
|
+
if raw_msgs is None:
|
|
190
|
+
raw_msgs = d.get("conversations") or []
|
|
191
|
+
raw_tools = d.get("tools") or []
|
|
192
|
+
return cls(
|
|
193
|
+
messages=[
|
|
194
|
+
ChatMessage.from_dict(m) for m in raw_msgs if isinstance(m, dict)
|
|
195
|
+
],
|
|
196
|
+
tools=[
|
|
197
|
+
ToolDef.from_dict(t) for t in raw_tools if isinstance(t, dict)
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def to_dict(self) -> dict[str, Any]:
|
|
202
|
+
return {
|
|
203
|
+
"messages": [m.to_dict() for m in self.messages],
|
|
204
|
+
"tools": [t.to_dict() for t in self.tools],
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ─── Iteration helpers used by ConversationSample & loaders ──────────
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def iter_assistant_tool_calls(
|
|
212
|
+
conv: Conversation,
|
|
213
|
+
) -> list[tuple[int, ToolCall]]:
|
|
214
|
+
"""Yield (message_index, ToolCall) for every assistant tool call.
|
|
215
|
+
|
|
216
|
+
Used by loaders that need to emit one training sample *per tool
|
|
217
|
+
call* — the canonical Caudate training granularity is "given this
|
|
218
|
+
prefix, what tool gets called next?". Each tool call inside an
|
|
219
|
+
assistant message is one training row."""
|
|
220
|
+
out: list[tuple[int, ToolCall]] = []
|
|
221
|
+
for i, msg in enumerate(conv.messages):
|
|
222
|
+
if msg.role != "assistant":
|
|
223
|
+
continue
|
|
224
|
+
for tc in msg.tool_calls:
|
|
225
|
+
out.append((i, tc))
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
__all__ = [
|
|
230
|
+
"ChatMessage",
|
|
231
|
+
"ToolCall",
|
|
232
|
+
"ToolDef",
|
|
233
|
+
"Conversation",
|
|
234
|
+
"iter_assistant_tool_calls",
|
|
235
|
+
]
|
nn/heads.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Caudate head registry — pluggable output specialists on a shared trunk.
|
|
2
|
+
|
|
3
|
+
Caudate is a single neural network. The transformer trunk produces one
|
|
4
|
+
pooled CLS representation per turn; multiple *heads* on top of that
|
|
5
|
+
representation each predict one categorical or scalar target. Each head
|
|
6
|
+
is one **routing job** (which tool, which tier, should-think, expected
|
|
7
|
+
reward, and — added in this refactor — preference, memory-write, cache,
|
|
8
|
+
permission, …).
|
|
9
|
+
|
|
10
|
+
The registry pattern means:
|
|
11
|
+
|
|
12
|
+
- Adding a new routing job = appending one `HeadSpec` to a list. No
|
|
13
|
+
surgery in `Caudate` itself.
|
|
14
|
+
- The trainer iterates the spec list to compute one loss per head and
|
|
15
|
+
aggregate them by `loss_weight`.
|
|
16
|
+
- State-dict layout matches the legacy `_Heads` module exactly
|
|
17
|
+
(`heads.tool.*`, `heads.tier.*`, …) so existing `caudate.pt` files
|
|
18
|
+
load cleanly with `strict=False` — new heads get fresh init,
|
|
19
|
+
existing heads keep their trained weights.
|
|
20
|
+
|
|
21
|
+
Adding a head is *not* a forking-into-a-second-model operation. The
|
|
22
|
+
trunk weights keep absorbing gradient from every head, so each new
|
|
23
|
+
job makes the shared representation richer for the existing jobs too.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Literal
|
|
30
|
+
|
|
31
|
+
import torch
|
|
32
|
+
import torch.nn as nn
|
|
33
|
+
|
|
34
|
+
LossType = Literal["ce", "bce", "mse_sigmoid"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class HeadSpec:
|
|
39
|
+
"""One routing job on the shared trunk.
|
|
40
|
+
|
|
41
|
+
`name` is the module name in `state_dict` (`heads.<name>.0.weight`,
|
|
42
|
+
so picking it well matters for backward compat). `output_key` is
|
|
43
|
+
the key under which forward() returns this head's tensor — kept
|
|
44
|
+
distinct from `name` only because the legacy heads used different
|
|
45
|
+
conventions (`tool_logits` vs the new `value`/`think_logit`).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
name: str # module name (state_dict key)
|
|
49
|
+
out_dim: int # output dimension
|
|
50
|
+
output_key: str # key in forward()'s output dict
|
|
51
|
+
loss_type: LossType # ce | bce | mse_sigmoid
|
|
52
|
+
loss_weight: float = 1.0 # weight in total loss
|
|
53
|
+
hidden_ratio: float = 0.5 # hidden = round(d_model * hidden_ratio)
|
|
54
|
+
target_field: str = "" # batch field for the target; defaults to f"target_{name}"
|
|
55
|
+
optional_target: bool = False # if True, missing target in batch → skip this head's loss
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def target(self) -> str:
|
|
59
|
+
return self.target_field or f"target_{self.name}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------
|
|
63
|
+
# Standard head bundle: the 4 original Caudate routing jobs.
|
|
64
|
+
# ---------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
STANDARD_HEADS: tuple[HeadSpec, ...] = (
|
|
67
|
+
# NOTE: the `tool` head used to live here as a fixed-vocab CE
|
|
68
|
+
# classifier (out_dim=32). Phase-2 of the format migration replaced
|
|
69
|
+
# it with `ContrastiveToolHead` — see below — which scores candidate
|
|
70
|
+
# tools by description embedding rather than fixed slot. The tool
|
|
71
|
+
# head is NOT in the standard HeadSpec registry anymore because it
|
|
72
|
+
# needs custom forward + loss paths (variable per-sample candidate
|
|
73
|
+
# count, masked contrastive softmax). The trainer wires it
|
|
74
|
+
# separately.
|
|
75
|
+
HeadSpec(name="tier", out_dim=2, output_key="tier_logits",
|
|
76
|
+
loss_type="ce", loss_weight=0.5, hidden_ratio=0.5),
|
|
77
|
+
HeadSpec(name="think", out_dim=1, output_key="think_logit",
|
|
78
|
+
loss_type="bce", loss_weight=0.2, hidden_ratio=0.25),
|
|
79
|
+
HeadSpec(name="value", out_dim=1, output_key="value",
|
|
80
|
+
loss_type="mse_sigmoid", loss_weight=0.5, hidden_ratio=0.25),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------
|
|
84
|
+
# Extended heads — new routing jobs on the same brain.
|
|
85
|
+
#
|
|
86
|
+
# These are wired structurally now (D-foundation); their label
|
|
87
|
+
# collectors come online in D-heads. Each carries `optional_target=True`
|
|
88
|
+
# so a batch missing the target field just skips that head's loss
|
|
89
|
+
# instead of crashing — important for incremental rollout.
|
|
90
|
+
# ---------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
EXTENDED_HEADS: tuple[HeadSpec, ...] = (
|
|
93
|
+
# --- Original D-heads (memory / cache / permission) -----------------
|
|
94
|
+
HeadSpec(name="memory_write", out_dim=1, output_key="memory_write_logit",
|
|
95
|
+
loss_type="bce", loss_weight=0.15, hidden_ratio=0.25,
|
|
96
|
+
optional_target=True),
|
|
97
|
+
HeadSpec(name="cache_hit", out_dim=1, output_key="cache_hit_logit",
|
|
98
|
+
loss_type="bce", loss_weight=0.15, hidden_ratio=0.25,
|
|
99
|
+
optional_target=True),
|
|
100
|
+
HeadSpec(name="permission", out_dim=1, output_key="permission_logit",
|
|
101
|
+
loss_type="bce", loss_weight=0.15, hidden_ratio=0.25,
|
|
102
|
+
optional_target=True),
|
|
103
|
+
|
|
104
|
+
# --- Tier 1: response-shape predictors ------------------------------
|
|
105
|
+
# Each head pre-classifies the *shape* of the upcoming response so
|
|
106
|
+
# routing can act on the prediction instead of waiting for it.
|
|
107
|
+
HeadSpec(name="refusal", out_dim=1, output_key="refusal_logit",
|
|
108
|
+
loss_type="bce", loss_weight=0.10, hidden_ratio=0.25,
|
|
109
|
+
optional_target=True),
|
|
110
|
+
HeadSpec(name="code_response", out_dim=1, output_key="code_response_logit",
|
|
111
|
+
loss_type="bce", loss_weight=0.10, hidden_ratio=0.25,
|
|
112
|
+
optional_target=True),
|
|
113
|
+
HeadSpec(name="stall", out_dim=1, output_key="stall_logit",
|
|
114
|
+
loss_type="bce", loss_weight=0.10, hidden_ratio=0.25,
|
|
115
|
+
optional_target=True),
|
|
116
|
+
HeadSpec(name="difficulty", out_dim=3, output_key="difficulty_logits",
|
|
117
|
+
loss_type="ce", loss_weight=0.15, hidden_ratio=0.25,
|
|
118
|
+
optional_target=True),
|
|
119
|
+
HeadSpec(name="stop_iter", out_dim=1, output_key="stop_iter_logit",
|
|
120
|
+
loss_type="bce", loss_weight=0.10, hidden_ratio=0.25,
|
|
121
|
+
optional_target=True),
|
|
122
|
+
HeadSpec(name="compaction", out_dim=1, output_key="compaction_logit",
|
|
123
|
+
loss_type="bce", loss_weight=0.10, hidden_ratio=0.25,
|
|
124
|
+
optional_target=True),
|
|
125
|
+
|
|
126
|
+
# --- Tier 2: continuous + multi-output predictors -------------------
|
|
127
|
+
HeadSpec(name="latency_s", out_dim=1, output_key="latency_s",
|
|
128
|
+
loss_type="mse_sigmoid", loss_weight=0.10, hidden_ratio=0.25,
|
|
129
|
+
optional_target=True),
|
|
130
|
+
HeadSpec(name="token_budget", out_dim=1, output_key="token_budget",
|
|
131
|
+
loss_type="mse_sigmoid", loss_weight=0.10, hidden_ratio=0.25,
|
|
132
|
+
optional_target=True),
|
|
133
|
+
# 4-D mood (frustration, curiosity, satisfaction, urgency) — sigmoid
|
|
134
|
+
# so each axis is in [0,1] and they're independent (not softmax).
|
|
135
|
+
HeadSpec(name="mood_pred", out_dim=4, output_key="mood_pred",
|
|
136
|
+
loss_type="mse_sigmoid", loss_weight=0.10, hidden_ratio=0.25,
|
|
137
|
+
optional_target=True),
|
|
138
|
+
HeadSpec(name="subagent_spawn", out_dim=1, output_key="subagent_spawn_logit",
|
|
139
|
+
loss_type="bce", loss_weight=0.10, hidden_ratio=0.25,
|
|
140
|
+
optional_target=True),
|
|
141
|
+
|
|
142
|
+
# --- Forge feature-success head (ADR 0006 Phase A) ------------------
|
|
143
|
+
# Predicts P(feature succeeds | feature_text, model_source) for the
|
|
144
|
+
# Forge orchestrator. Label source is `data/nn/feature_outcomes.jsonl`
|
|
145
|
+
# — one row per completed (success or failure) Forge feature. The
|
|
146
|
+
# trunk's source-embedding already captures per-model bias, so this
|
|
147
|
+
# head only needs to read the pooled CLS conditioned on the feature
|
|
148
|
+
# text. hidden_ratio=0.5 because the signal is concentrated in the
|
|
149
|
+
# text+source pair, not in the broader turn-state.
|
|
150
|
+
HeadSpec(name="feature_success", out_dim=1, output_key="feature_success_logit",
|
|
151
|
+
loss_type="bce", loss_weight=0.20, hidden_ratio=0.5,
|
|
152
|
+
optional_target=True),
|
|
153
|
+
|
|
154
|
+
# --- Tier 4b: reward-model head -------------------------------------
|
|
155
|
+
# Scores the quality of an external draft (replaces the heuristic
|
|
156
|
+
# `_score_draft` in api/openai_compat.py once trained). Same shape
|
|
157
|
+
# as `value` but trained on arbitration pair labels. Sees pooled CLS
|
|
158
|
+
# of the turn-state, which is enough for "given this prompt, how
|
|
159
|
+
# likely is a strong response?" — the more precise per-draft scoring
|
|
160
|
+
# waits for the generative-decoder phase.
|
|
161
|
+
HeadSpec(name="reward_model", out_dim=1, output_key="reward_model_logit",
|
|
162
|
+
loss_type="mse_sigmoid", loss_weight=0.20, hidden_ratio=0.5,
|
|
163
|
+
optional_target=True),
|
|
164
|
+
|
|
165
|
+
# --- Tier 3: self-supervised auxiliaries ----------------------------
|
|
166
|
+
# next_prompt — predicts an embedding of the *next* user message
|
|
167
|
+
# given the current pooled CLS. Cosine-similarity loss against the
|
|
168
|
+
# encoder's own embedding of the actual next message (when it
|
|
169
|
+
# arrives). Auxiliary task that lifts trunk quality without any
|
|
170
|
+
# external label. Output is in d_model-dim CLS space so the loss
|
|
171
|
+
# is a direct cosine alignment.
|
|
172
|
+
# Note: out_dim matches the trunk's d_model (256). The mse_sigmoid
|
|
173
|
+
# loss isn't quite right for cosine alignment, so the trainer has a
|
|
174
|
+
# bespoke loss path for this head — see _aux_next_prompt_loss.
|
|
175
|
+
HeadSpec(name="next_prompt", out_dim=256, output_key="next_prompt_embed",
|
|
176
|
+
loss_type="mse_sigmoid", loss_weight=0.05, hidden_ratio=1.0,
|
|
177
|
+
optional_target=True),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
ALL_HEADS: tuple[HeadSpec, ...] = STANDARD_HEADS + EXTENDED_HEADS
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------
|
|
184
|
+
# Module
|
|
185
|
+
# ---------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _make_head(d_model: int, spec: HeadSpec, dropout: float) -> nn.Module:
|
|
189
|
+
hidden = max(1, int(d_model * spec.hidden_ratio))
|
|
190
|
+
if spec.hidden_ratio >= 1.0:
|
|
191
|
+
# Wider heads (tool) include dropout to control overfitting on
|
|
192
|
+
# the larger fan-in.
|
|
193
|
+
return nn.Sequential(
|
|
194
|
+
nn.Linear(d_model, hidden),
|
|
195
|
+
nn.GELU(),
|
|
196
|
+
nn.Dropout(dropout),
|
|
197
|
+
nn.Linear(hidden, spec.out_dim),
|
|
198
|
+
)
|
|
199
|
+
return nn.Sequential(
|
|
200
|
+
nn.Linear(d_model, hidden),
|
|
201
|
+
nn.GELU(),
|
|
202
|
+
nn.Linear(hidden, spec.out_dim),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class HeadRegistry(nn.ModuleDict):
|
|
207
|
+
"""ModuleDict of named heads + the spec list driving the forward pass.
|
|
208
|
+
|
|
209
|
+
Inheriting from `ModuleDict` (rather than holding one as an attr)
|
|
210
|
+
keeps the state-dict layout flat (`heads.<name>.*`) so legacy
|
|
211
|
+
checkpoints load cleanly.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(self, d_model: int, dropout: float, specs: tuple[HeadSpec, ...]):
|
|
215
|
+
super().__init__({s.name: _make_head(d_model, s, dropout) for s in specs})
|
|
216
|
+
# `_specs` is a plain Python attr (not a Module) — won't pollute state_dict
|
|
217
|
+
object.__setattr__(self, "_specs", tuple(specs))
|
|
218
|
+
|
|
219
|
+
# Xavier init on linear layers; zero bias.
|
|
220
|
+
for m in self.modules():
|
|
221
|
+
if isinstance(m, nn.Linear):
|
|
222
|
+
nn.init.xavier_uniform_(m.weight)
|
|
223
|
+
if m.bias is not None:
|
|
224
|
+
nn.init.zeros_(m.bias)
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def specs(self) -> tuple[HeadSpec, ...]:
|
|
228
|
+
return self._specs # type: ignore[attr-defined]
|
|
229
|
+
|
|
230
|
+
def forward(self, cls: torch.Tensor) -> dict[str, torch.Tensor]:
|
|
231
|
+
out: dict[str, torch.Tensor] = {}
|
|
232
|
+
for spec in self.specs:
|
|
233
|
+
y = self[spec.name](cls)
|
|
234
|
+
if spec.out_dim == 1:
|
|
235
|
+
y = y.squeeze(-1) # (B, 1) → (B,)
|
|
236
|
+
out[spec.output_key] = y
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------
|
|
241
|
+
# Loss helpers — used by the trainer
|
|
242
|
+
# ---------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def head_loss(spec: HeadSpec, output: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
|
246
|
+
if spec.loss_type == "ce":
|
|
247
|
+
return nn.functional.cross_entropy(output, target.long())
|
|
248
|
+
if spec.loss_type == "bce":
|
|
249
|
+
return nn.functional.binary_cross_entropy_with_logits(output, target.float())
|
|
250
|
+
if spec.loss_type == "mse_sigmoid":
|
|
251
|
+
return nn.functional.mse_loss(torch.sigmoid(output), target.float())
|
|
252
|
+
raise ValueError(f"unknown loss_type {spec.loss_type!r} for head {spec.name!r}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def head_accuracy(spec: HeadSpec, output: torch.Tensor, target: torch.Tensor) -> float | None:
|
|
256
|
+
"""Return classification accuracy for ce/bce; None for regression heads."""
|
|
257
|
+
with torch.no_grad():
|
|
258
|
+
if spec.loss_type == "ce":
|
|
259
|
+
return (output.argmax(-1) == target.long()).float().mean().item()
|
|
260
|
+
if spec.loss_type == "bce":
|
|
261
|
+
return ((output > 0).float() == target.float()).float().mean().item()
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------
|
|
266
|
+
# Contrastive tool head — open-vocab, replaces the fixed 32-slot
|
|
267
|
+
# classifier. Phase 2 of the format migration.
|
|
268
|
+
# ---------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class ContrastiveToolHead(nn.Module):
|
|
272
|
+
"""Open-vocabulary tool predictor.
|
|
273
|
+
|
|
274
|
+
Architecture: pooled CLS → ``query`` vector. Each candidate tool's
|
|
275
|
+
"{name}: {description}" string → ``tool_emb`` via the shared text
|
|
276
|
+
encoder, projected into the same space. Score = query · tool_emb;
|
|
277
|
+
softmax over the per-sample candidate set yields the tool
|
|
278
|
+
distribution. Invalid slots (when a sample has fewer than
|
|
279
|
+
``max_tools_per_sample`` candidates) are masked to -inf so they
|
|
280
|
+
contribute zero probability.
|
|
281
|
+
|
|
282
|
+
Why contrastive instead of fixed-slot:
|
|
283
|
+
- Open vocabulary — train on 80K xLAM tools, generalize to tools
|
|
284
|
+
never seen at training time by reading their description.
|
|
285
|
+
- Variable candidate sets per turn — different sessions expose
|
|
286
|
+
different tool registries; the head respects that.
|
|
287
|
+
- Zero "tool collision" problem — the old vocab-mod-N approach
|
|
288
|
+
would conflate distinct tools onto the same slot once vocab
|
|
289
|
+
size > n_tools_out.
|
|
290
|
+
|
|
291
|
+
Why this isn't a HeadSpec:
|
|
292
|
+
- Its forward needs the per-sample candidate list (not just the
|
|
293
|
+
pooled CLS), so the generic HeadRegistry signature doesn't fit.
|
|
294
|
+
- Its loss is a contrastive softmax with masking, not the
|
|
295
|
+
standard CE/BCE/MSE path the generic loss helper supports.
|
|
296
|
+
|
|
297
|
+
The trainer wires this head explicitly. ``Caudate.forward``
|
|
298
|
+
returns its logits under ``"tool_logits"`` and its mask under
|
|
299
|
+
``"tool_mask"`` so downstream paths can mix accuracy and
|
|
300
|
+
confidence inspection.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
def __init__(self, cfg, text_embedder):
|
|
304
|
+
super().__init__()
|
|
305
|
+
self.cfg = cfg
|
|
306
|
+
# Query projection: pooled CLS → tool-embedding space
|
|
307
|
+
d_out = cfg.n_tools_out # now: tool embedding dim, not # of slots
|
|
308
|
+
self.query_proj = nn.Sequential(
|
|
309
|
+
nn.Linear(cfg.d_model, cfg.d_model),
|
|
310
|
+
nn.GELU(),
|
|
311
|
+
nn.Dropout(cfg.dropout),
|
|
312
|
+
nn.Linear(cfg.d_model, d_out),
|
|
313
|
+
)
|
|
314
|
+
# Tool description projection: text encoder output → same space
|
|
315
|
+
self.tool_proj = nn.Sequential(
|
|
316
|
+
nn.Linear(cfg.text_embed_dim, cfg.d_model),
|
|
317
|
+
nn.GELU(),
|
|
318
|
+
nn.Linear(cfg.d_model, d_out),
|
|
319
|
+
)
|
|
320
|
+
# Shared with the state encoder; we don't own it, we use it.
|
|
321
|
+
# Frozen sentence-transformer (or hash-embed fallback offline).
|
|
322
|
+
self._text_embedder = text_embedder
|
|
323
|
+
# Learned temperature so the softmax sharpness can adapt.
|
|
324
|
+
self.log_temperature = nn.Parameter(torch.zeros(1))
|
|
325
|
+
|
|
326
|
+
# Init
|
|
327
|
+
for m in self.modules():
|
|
328
|
+
if isinstance(m, nn.Linear):
|
|
329
|
+
nn.init.xavier_uniform_(m.weight)
|
|
330
|
+
if m.bias is not None:
|
|
331
|
+
nn.init.zeros_(m.bias)
|
|
332
|
+
|
|
333
|
+
@torch.no_grad()
|
|
334
|
+
def _encode_descriptions(self, flat_texts: list[str]) -> torch.Tensor:
|
|
335
|
+
"""Encode a flat list of tool description strings into the
|
|
336
|
+
shared text-embedding space. Frozen — no gradient flows back
|
|
337
|
+
through the sentence-transformer.
|
|
338
|
+
"""
|
|
339
|
+
if not flat_texts:
|
|
340
|
+
return torch.zeros(
|
|
341
|
+
(0, self.cfg.text_embed_dim),
|
|
342
|
+
device=next(self.parameters()).device,
|
|
343
|
+
)
|
|
344
|
+
return self._text_embedder.encode(flat_texts)
|
|
345
|
+
|
|
346
|
+
def embed_tools(
|
|
347
|
+
self,
|
|
348
|
+
tool_specs: list[list[str]],
|
|
349
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
350
|
+
"""Encode per-sample candidate tool strings into projected
|
|
351
|
+
embeddings + a validity mask.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
tool_specs: B-sized list, each entry is a list of
|
|
355
|
+
"{name}: {description}" strings (zero-or-more).
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
tool_emb: (B, K, n_tools_out) zero-padded
|
|
359
|
+
mask: (B, K) bool — True at valid slots
|
|
360
|
+
"""
|
|
361
|
+
device = next(self.parameters()).device
|
|
362
|
+
B = len(tool_specs)
|
|
363
|
+
K = self.cfg.max_tools_per_sample
|
|
364
|
+
|
|
365
|
+
# Flatten across samples for one batched embedder call.
|
|
366
|
+
flat: list[str] = []
|
|
367
|
+
per_sample_count: list[int] = []
|
|
368
|
+
for ts in tool_specs:
|
|
369
|
+
kept = list(ts)[:K]
|
|
370
|
+
per_sample_count.append(len(kept))
|
|
371
|
+
flat.extend(kept)
|
|
372
|
+
|
|
373
|
+
if flat:
|
|
374
|
+
text_embs = self._encode_descriptions(flat).to(device)
|
|
375
|
+
proj = self.tool_proj(text_embs) # (N_flat, n_tools_out)
|
|
376
|
+
else:
|
|
377
|
+
proj = torch.zeros((0, self.cfg.n_tools_out), device=device)
|
|
378
|
+
|
|
379
|
+
out = torch.zeros(B, K, self.cfg.n_tools_out, device=device)
|
|
380
|
+
mask = torch.zeros(B, K, dtype=torch.bool, device=device)
|
|
381
|
+
cursor = 0
|
|
382
|
+
for i, k in enumerate(per_sample_count):
|
|
383
|
+
if k > 0:
|
|
384
|
+
out[i, :k] = proj[cursor:cursor + k]
|
|
385
|
+
mask[i, :k] = True
|
|
386
|
+
cursor += k
|
|
387
|
+
return out, mask
|
|
388
|
+
|
|
389
|
+
def forward(
|
|
390
|
+
self,
|
|
391
|
+
pooled_cls: torch.Tensor,
|
|
392
|
+
tool_emb: torch.Tensor,
|
|
393
|
+
mask: torch.Tensor,
|
|
394
|
+
) -> torch.Tensor:
|
|
395
|
+
"""Compute (B, K) logits over candidate tools. Invalid slots
|
|
396
|
+
are returned as a very negative number so any softmax over
|
|
397
|
+
the result effectively ignores them."""
|
|
398
|
+
query = self.query_proj(pooled_cls) # (B, n_tools_out)
|
|
399
|
+
# Dot product per candidate: (B, 1, d) * (B, K, d) sum→ (B, K)
|
|
400
|
+
scores = (tool_emb * query.unsqueeze(1)).sum(dim=-1)
|
|
401
|
+
temp = self.log_temperature.exp().clamp(max=10.0)
|
|
402
|
+
scores = scores * temp
|
|
403
|
+
return scores.masked_fill(~mask, -1e9)
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def contrastive_loss(
|
|
407
|
+
logits: torch.Tensor,
|
|
408
|
+
target_idx: torch.Tensor,
|
|
409
|
+
valid: torch.Tensor,
|
|
410
|
+
slot_0_weight: float = 1.0,
|
|
411
|
+
) -> torch.Tensor:
|
|
412
|
+
"""Cross-entropy on rows with a valid target. target_idx == -1
|
|
413
|
+
marks rows we should skip (no labeled positive in the
|
|
414
|
+
candidate set). Returns a scalar; returns zero if no row is
|
|
415
|
+
valid (so the optimiser sees no gradient from this batch).
|
|
416
|
+
|
|
417
|
+
``slot_0_weight`` rescales the loss contribution for rows whose
|
|
418
|
+
target is slot-0 (the synthetic ``<no_tool>`` class). The cognos-
|
|
419
|
+
toolbox corpus is 63% ``<no_tool>``, which biases the head's
|
|
420
|
+
argmax toward slot-0. Setting ``slot_0_weight < 1.0`` re-balances
|
|
421
|
+
so real-tool slots get more gradient. ``1.0`` (default) preserves
|
|
422
|
+
the original unweighted behavior."""
|
|
423
|
+
if not bool(valid.any()):
|
|
424
|
+
return logits.sum() * 0.0 # zero-grad scalar with graph
|
|
425
|
+
weight = None
|
|
426
|
+
if slot_0_weight != 1.0:
|
|
427
|
+
K = logits.shape[-1]
|
|
428
|
+
weight = torch.ones(K, device=logits.device, dtype=logits.dtype)
|
|
429
|
+
weight[0] = float(slot_0_weight)
|
|
430
|
+
return torch.nn.functional.cross_entropy(
|
|
431
|
+
logits[valid], target_idx[valid].long(), weight=weight,
|
|
432
|
+
)
|