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/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
+ )