Spaces:
Running on Zero
Running on Zero
File size: 22,403 Bytes
48ee375 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 | #!/usr/bin/env python3
"""Generate the behavioral SFT dataset the deployed adapter is missing.
The current adapter (sft_v2 + agent traces) was trained exclusively on
fully-specified control problems mapped to the matching design tool. As a
result the model:
* routes ANY matrix-shaped question to a control-design tool (asked to
multiply two matrices, it ran an LQR synthesis),
* fabricates missing parameters (invented B/Q/R three separate times)
because it never saw a single example of asking for a missing value,
* carries numbers over from a previous, different problem in multi-turn
conversations.
This script generates verified trajectories for exactly those behaviors:
1. plain linear-algebra routing -> matrix_arithmetic tool traces
2. missing-parameter refusal -> no tool call; state what's missing, ask
3. poisoned-history faithfulness -> multi-turn: full problem in turn 1,
different request in turn 2 answered
WITHOUT reusing turn-1 numbers
4. conceptual questions -> prose answer, no unnecessary code tool
Every numeric answer is produced by executing the real registered tool, so
the dataset is verified by construction.
Usage:
python3 scripts/generate_behavior_sft_dataset.py
"""
from __future__ import annotations
import json
import random
import sys
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
from controlai_agent.registry import registry
import controlai_agent.tools # noqa: F401 (register all tools)
OUTPUT_DIR = PROJECT_ROOT / "data" / "training" / "behavior_sft_v1"
RNG_SEED = 20260819
SYSTEM_PROMPT = (
"You are an offline control-systems engineering assistant. Lead with the result, "
"state assumptions and conventions, show the decisive calculation, and never invent "
"a plant, parameter, software output, or verification result. Use executable Python "
"or MATLAB only when requested."
)
def fmt_num(x: float) -> str:
return f"{x:.6g}"
def fmt_matrix(mat: list[list[float]]) -> str:
return "[" + ", ".join("[" + ", ".join(fmt_num(v) for v in row) + "]" for row in mat) + "]"
def fmt_matrix_latex(mat) -> str:
arr = np.atleast_2d(np.array(mat, dtype=float))
rows = [" & ".join(fmt_num(v) for v in row) for row in arr]
return "\\begin{bmatrix} " + " \\\\ ".join(rows) + " \\end{bmatrix}"
def rand_int_matrix(rng: random.Random, rows: int, cols: int) -> list[list[float]]:
return [[float(rng.randint(-9, 9)) for _ in range(cols)] for _ in range(rows)]
def rand_nonsingular(rng: random.Random, n: int) -> list[list[float]]:
while True:
m = rand_int_matrix(rng, n, n)
if abs(np.linalg.det(np.array(m))) > 0.5:
return m
def matrix_to_text(mat: list[list[float]], rng: random.Random) -> str:
"""Render a matrix as the user might actually type it, garbling included."""
style = rng.random()
body = "[" + ", ".join("[" + ", ".join(fmt_num(v) for v in row) + "]" for row in mat) + "]"
if style < 0.7:
return body
# space-separated rows, occasionally with a dropped comma (real user input)
rows = []
for row in mat:
if rng.random() < 0.5:
rows.append("[" + " ".join(fmt_num(v) for v in row) + "]")
else:
rows.append("[" + ", ".join(fmt_num(v) for v in row) + "]")
return "[" + ", ".join(rows) + "]"
def tool_call_msg(name: str, args: dict[str, Any]) -> dict[str, str]:
return {
"role": "assistant",
"content": f"<tool_call>\n{json.dumps({'name': name, 'arguments': args}, ensure_ascii=False)}\n</tool_call>",
}
def tool_result_msg(name: str, result: dict[str, Any]) -> dict[str, str]:
return {"role": "tool", "name": name, "content": json.dumps(result, ensure_ascii=False)}
def make_example(messages: list[dict[str, str]]) -> dict[str, Any]:
return {"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + messages}
# ---------------------------------------------------------------------------
# 1. Plain linear-algebra routing
# ---------------------------------------------------------------------------
MULT_PHRASES = [
"{a} times {b}",
"Multiply {a} by {b}",
"Compute the matrix product of A = {a} and B = {b}",
"A = {a} times A = {b}",
"What is {a} * {b}?",
"{a} multiplied by {b}",
]
UNARY_TEMPLATES = {
"inverse": ["Invert the matrix {a}", "What is the inverse of {a}?", "Compute {a}^-1"],
"determinant": ["Determinant of {a}", "Compute det({a})", "What is the determinant of the matrix {a}?"],
"rank": ["What is the rank of {a}?", "Compute the rank of the matrix {a}"],
"transpose": ["Transpose {a}", "What is {a} transposed?"],
"eigenvalues": ["Eigenvalues of {a}", "Find the eigenvalues of the matrix {a}", "Compute the spectrum of {a}"],
"trace": ["What is the trace of {a}?", "Compute the trace of {a}"],
}
def gen_multiply(rng: random.Random) -> dict[str, Any] | None:
n = rng.choice([2, 2, 3, 3, 4])
k = rng.choice([n, n, rng.choice([1, 2, 3])])
A = rand_int_matrix(rng, n, k)
B = rand_int_matrix(rng, k, rng.choice([1, 2, 3, n]))
args = {"operation": "multiply", "matrix_a": A, "matrix_b": B}
result = registry.execute("matrix_arithmetic", args)
if result.get("status") != "success":
return None
prompt = rng.choice(MULT_PHRASES).format(a=matrix_to_text(A, rng), b=matrix_to_text(B, rng))
product = result["result"]
final = (
f"The matrix product is:\n$$AB = {fmt_matrix_latex(product)}$$\n"
f"({len(A)}x{len(A[0])} times {len(B)}x{len(B[0])} gives a {len(product)}x{len(product[0])} result; "
f"entry $(i,j)$ is the dot product of row $i$ of the first matrix with column $j$ of the second.)"
)
return make_example([
{"role": "user", "content": prompt},
tool_call_msg("matrix_arithmetic", args),
tool_result_msg("matrix_arithmetic", result),
{"role": "assistant", "content": final},
])
def gen_unary(rng: random.Random) -> dict[str, Any] | None:
op = rng.choice(list(UNARY_TEMPLATES.keys()))
n = rng.choice([2, 2, 3, 3])
A = rand_nonsingular(rng, n) if op == "inverse" else (
rand_int_matrix(rng, n, n) if op in ("determinant", "eigenvalues", "trace") else rand_int_matrix(rng, n, rng.choice([n, n + 1]))
)
args = {"operation": op, "matrix_a": A}
result = registry.execute("matrix_arithmetic", args)
if result.get("status") != "success":
return None
prompt = rng.choice(UNARY_TEMPLATES[op]).format(a=matrix_to_text(A, rng))
res = result["result"]
if op == "inverse":
final = f"The inverse is:\n$$A^{{-1}} = {fmt_matrix_latex(res)}$$\n(det $= {fmt_num(result['determinant'])} \\neq 0$, so the matrix is invertible.)"
elif op == "transpose":
final = f"The transpose is:\n$$A^T = {fmt_matrix_latex(res)}$$"
elif op == "eigenvalues":
eig_strs = [f"{fmt_num(re_)}" + (f" {'+' if im >= 0 else '-'} {fmt_num(abs(im))}j" if abs(im) > 1e-12 else "") for re_, im in res]
final = f"The eigenvalues are:\n$$\\lambda = \\{{{', '.join(eig_strs)}\\}}$$\nSpectral radius: $\\rho(A) = {fmt_num(result['spectral_radius'])}$."
elif op == "rank":
full = "full rank" if res == min(len(A), len(A[0])) else f"rank-deficient (max possible {min(len(A), len(A[0]))})"
final = f"$\\text{{rank}}(A) = {res}$ -- the matrix is {full}."
else:
final = f"$\\text{{{op}}}(A) = {fmt_num(res)}$."
return make_example([
{"role": "user", "content": prompt},
tool_call_msg("matrix_arithmetic", args),
tool_result_msg("matrix_arithmetic", result),
{"role": "assistant", "content": final},
])
# ---------------------------------------------------------------------------
# 2. Missing-parameter refusal (single-turn)
# ---------------------------------------------------------------------------
def gen_refusal_single(rng: random.Random) -> dict[str, Any]:
n = rng.choice([2, 3])
A = rand_int_matrix(rng, n, n)
kind = rng.choice(["lqr_no_b", "lqr_no_qr", "place_no_poles", "zoh_no_ts", "margins_no_tf", "kalman_no_cov"])
if kind == "lqr_no_b":
prompt = rng.choice([
f"Design an LQR controller for A={fmt_matrix(A)}.",
f"Solve the LQR problem for the system with state matrix A={fmt_matrix(A)} and simulate the closed loop.",
f"Find the optimal LQR gain for A={fmt_matrix(A)}, Q=diag({', '.join(str(rng.randint(1, 20)) for _ in range(n))}), R=1.",
])
answer = (
f"I can't run the LQR synthesis yet: the input matrix $B$ ({n}x m) has not been provided, and I "
f"won't assume one -- the gain $K$ depends entirely on how the input enters the dynamics.\n\n"
f"What I can tell you from $A$ alone: the open-loop eigenvalues determine whether the plant is "
f"stable before feedback, and controllability of $(A, B)$ must be checked once $B$ is known.\n\n"
f"Please provide $B$ (and $Q$, $R$ if you want other than default weights), and I will compute "
f"$P$, $K$, and the closed-loop poles."
)
elif kind == "lqr_no_qr":
B = [[0.0] for _ in range(n - 1)] + [[1.0]]
prompt = f"Compute the LQR gain for A={fmt_matrix(A)}, B={fmt_matrix(B)}."
answer = (
"The LQR gain is not defined until the cost is: I need the state penalty $Q$ (positive "
"semidefinite, {n}x{n}) and the control penalty $R$ (positive definite). Different $Q, R$ give "
"completely different gains for the same plant.\n\nTell me $Q$ and $R$ -- or tell me what "
"behavior you want (e.g. fast state 1, cheap control) and I will propose weights and say so "
"explicitly as a design choice."
).replace("{n}", str(n))
elif kind == "place_no_poles":
B = [[0.0] for _ in range(n - 1)] + [[1.0]]
prompt = f"Place the closed-loop poles for A={fmt_matrix(A)}, B={fmt_matrix(B)} using state feedback."
answer = (
"Pole placement needs target pole locations, and none were given. Where do you want the "
f"closed-loop poles? Provide {n} locations (complex ones in conjugate pairs, e.g. $-2 \\pm 3j$), "
"or give a specification (settling time, damping ratio) and I will derive pole locations from it "
"and state that derivation explicitly."
)
elif kind == "zoh_no_ts":
B = [[0.0] for _ in range(n - 1)] + [[1.0]]
prompt = f"Discretize A={fmt_matrix(A)}, B={fmt_matrix(B)} using zero-order hold."
answer = (
"Exact ZOH discretization requires the sampling period $T_s$, which was not given -- $A_d = "
"e^{A T_s}$ changes completely with it. What is $T_s$ (in seconds)? As a rule of thumb it should "
"give 10-30 samples per dominant closed-loop time constant."
)
elif kind == "margins_no_tf":
prompt = rng.choice([
"Compute the gain and phase margins of my loop.",
"What are the stability margins of the system?",
])
answer = (
"I need the open-loop transfer function to compute margins -- numerator and denominator "
"coefficients of $L(s)$, or its poles/zeros/gain, or the state-space matrices. Please provide "
"one of those and I will compute GM, PM, and both crossover frequencies exactly."
)
else: # kalman_no_cov
prompt = f"Run a Kalman filter time update for A={fmt_matrix(A)}."
answer = (
"A Kalman time update needs more than $A$: the current state estimate $\\hat{x}$, its covariance "
"$P$, and the process noise covariance $Q_w$ (plus $B$ and $u$ if there is a known input). "
"Which values should I use? I will not assume noise statistics -- they determine the filter "
"entirely."
)
return make_example([
{"role": "user", "content": prompt},
{"role": "assistant", "content": answer},
])
# ---------------------------------------------------------------------------
# 3. Poisoned-history faithfulness (multi-turn)
# ---------------------------------------------------------------------------
def _full_lqr_turn(rng: random.Random) -> tuple[list[dict[str, str]], dict[str, Any]]:
"""A complete verified LQR exchange to serve as turn 1 (the 'poison')."""
A = [[0.0, 1.0], [float(-rng.randint(1, 5)), float(-rng.randint(1, 5))]]
B = [[0.0], [1.0]]
q1, q2 = rng.choice([(10, 1), (20, 1), (5, 2), (100, 1)])
Q = [[float(q1), 0.0], [0.0, float(q2)]]
R = [[1.0]]
args = {"A": A, "B": B, "Q": Q, "R": R}
result = registry.execute("continuous_lqr", args)
prompt = f"Design an LQR controller for A={fmt_matrix(A)}, B={fmt_matrix(B)}, Q=diag([{q1}, {q2}]), R=1."
K = result["K"]
poles = result["closed_loop_poles"]
pole_strs = [f"{fmt_num(p[0])}" + (f" {'+' if p[1] >= 0 else '-'} {fmt_num(abs(p[1]))}j" if abs(p[1]) > 1e-9 else "") for p in poles]
final = (
f"Solving the CARE gives the optimal gain $K = {fmt_matrix_latex(K)}$ with closed-loop poles "
f"$\\{{{', '.join(pole_strs)}\\}}$ -- the closed loop is asymptotically stable."
)
turn = [
{"role": "user", "content": prompt},
tool_call_msg("continuous_lqr", args),
tool_result_msg("continuous_lqr", result),
{"role": "assistant", "content": final},
]
return turn, args
def gen_poisoned_matrix_question(rng: random.Random) -> dict[str, Any] | None:
"""Turn 1: full LQR. Turn 2: plain matrix question -> matrix tool only."""
turn1, _ = _full_lqr_turn(rng)
n = rng.choice([2, 3])
X = rand_int_matrix(rng, n, n)
Y = rand_int_matrix(rng, n, n)
args = {"operation": "multiply", "matrix_a": X, "matrix_b": Y}
result = registry.execute("matrix_arithmetic", args)
if result.get("status") != "success":
return None
prompt = rng.choice(MULT_PHRASES).format(a=matrix_to_text(X, rng), b=matrix_to_text(Y, rng))
final = f"The matrix product is:\n$$XY = {fmt_matrix_latex(result['result'])}$$"
return make_example(turn1 + [
{"role": "user", "content": prompt},
tool_call_msg("matrix_arithmetic", args),
tool_result_msg("matrix_arithmetic", result),
{"role": "assistant", "content": final},
])
def gen_poisoned_refusal(rng: random.Random) -> dict[str, Any]:
"""Turn 1: full 2-state LQR. Turn 2: new 3-state A with no B -> refuse,
explicitly declining to reuse the old problem's matrices."""
turn1, old_args = _full_lqr_turn(rng)
A3 = rand_int_matrix(rng, 3, 3)
prompt = rng.choice([
f"Now design an LQR controller for A={fmt_matrix(A3)}.",
f"Do the same for A={fmt_matrix(A3)}.",
f"LQR for A={fmt_matrix(A3)} please.",
])
answer = (
"This is a new 3-state system, and I only have its $A$ matrix. The $B$, $Q$, and $R$ from the "
"previous problem belong to a different 2-state plant -- they do not carry over, and resizing them "
"to fit would be inventing a model.\n\nTo run this synthesis I need: $B$ (3 x m), and the weights "
"$Q$ (3x3 PSD) and $R$ (m x m PD), or a description of the actuation and objectives so I can "
"propose weights as an explicit design choice. What is $B$ for this system?"
)
return make_example(turn1 + [
{"role": "user", "content": prompt},
{"role": "assistant", "content": answer},
])
def gen_poisoned_concept_question(rng: random.Random) -> dict[str, Any]:
"""Turn 1: full LQR. Turn 2: conceptual question -> prose, no tools, no old numbers."""
turn1, _ = _full_lqr_turn(rng)
concept, answer = rng.choice(CONCEPT_QA)
return make_example(turn1 + [
{"role": "user", "content": concept},
{"role": "assistant", "content": answer},
])
# ---------------------------------------------------------------------------
# 4. Conceptual questions -> prose, no unnecessary tools
# ---------------------------------------------------------------------------
CONCEPT_QA: list[tuple[str, str]] = [
(
"What is the difference between controllability and stabilizability?",
"**Controllability** means every state can be driven anywhere in finite time: $\\text{rank}[B\\ AB\\ \\cdots\\ A^{n-1}B] = n$, or equivalently the PBH test $\\text{rank}[\\lambda I - A\\ \\ B] = n$ for every eigenvalue $\\lambda$.\n\n**Stabilizability** is weaker: only the *unstable* modes must be controllable -- PBH needs to hold only for $\\text{Re}(\\lambda) \\geq 0$. Stable uncontrollable modes are acceptable because they decay on their own.\n\nEvery controllable system is stabilizable; the converse fails. Stabilizability (with detectability) is exactly what LQR/LQG existence requires -- full controllability is sufficient but not necessary.",
),
(
"Why does a phase margin around 45-60 degrees correspond to good damping?",
"For a second-order-dominant loop, phase margin and closed-loop damping are linked approximately by $\\zeta \\approx PM/100$ (PM in degrees), so $PM = 45^\\circ$--$60^\\circ$ maps to $\\zeta \\approx 0.45$--$0.6$: fast response with modest overshoot (roughly 10-25%).\n\nBelow ~30° the closed loop rings badly and is fragile to delay ($\\Delta\\phi = \\omega_{gc} T_d$ eats margin directly); much above 70° the response is sluggish. The linear rule degrades when higher-order dynamics or RHP zeros distort the phase near crossover -- then the exact relation, not the approximation, must be used.",
),
(
"What is integral windup in a PID loop and how is it prevented?",
"When the actuator saturates, the plant stops responding to further increases in the control signal, but the integrator keeps accumulating error. The integral term 'winds up' far beyond what the actuator can deliver; after the error changes sign it takes a long time to unwind, causing large overshoot and slow recovery.\n\nStandard remedies: **conditional integration** (freeze the integrator while saturated), **back-calculation** (feed the difference between commanded and saturated actuator output back to discharge the integrator through gain $1/T_t$), or designing with actuator limits explicitly (MPC). Back-calculation is the common industrial choice because it degrades gracefully.",
),
(
"When would you choose MPC over a well-tuned PID controller?",
"Choose **MPC** when the problem has structure PID cannot represent: hard constraints on inputs/states that the controller should respect *by design* rather than by saturation, strong multivariable interaction (coupled MIMO loops), a good process model with significant dead time, or an economic objective over a horizon.\n\nStay with **PID** when the loop is essentially SISO, fast, and model-poor: PID needs almost no model, runs at microsecond rates on a PLC, and its failure modes are well understood on the plant floor. A practical rule: constraints and coupling pay for MPC's modeling and maintenance cost; without them, they don't.",
),
(
"What does the separation principle say for observer-based control?",
"For a linear system with state feedback $u = -K\\hat{x}$ driven by an observer with gain $L$, the closed-loop eigenvalues are exactly the union of the state-feedback poles $\\lambda(A - BK)$ and the observer poles $\\lambda(A - LC)$: the estimation error dynamics decouple from the regulation dynamics.\n\nSo $K$ and $L$ may be designed independently -- pole placement or LQR for $K$, pole placement or a Kalman filter for $L$ (the LQG combination). The caveat: separation guarantees *nominal* stability only. The combined loop can have poor robustness margins (Doyle's 1978 counterexample: LQG has no guaranteed margins), so robustness must be checked on the assembled loop.",
),
(
"Explain the waterbed effect in feedback design.",
"Bode's sensitivity integral: for an open-loop stable, minimum-phase loop with relative degree ≥ 2, $\\int_0^\\infty \\ln|S(j\\omega)|\\, d\\omega = 0$ (and $= \\pi \\sum \\text{Re}(p_i)$ with unstable poles $p_i$). Pushing $|S|$ below 1 over one band necessarily pushes it above 1 somewhere else -- like pressing on a waterbed.\n\nConsequences: disturbance rejection improved in one band is paid for with amplification elsewhere; RHP zeros make it worse by confining where the 'bulge' can go. It is a conservation law, not a design flaw -- good loop shaping *places* the sensitivity bulge where disturbances and model error are least harmful.",
),
]
def gen_concept(rng: random.Random) -> dict[str, Any]:
q, a = rng.choice(CONCEPT_QA)
return make_example([
{"role": "user", "content": q},
{"role": "assistant", "content": a},
])
# ---------------------------------------------------------------------------
def main() -> None:
rng = random.Random(RNG_SEED)
examples: list[dict[str, Any]] = []
generators = [
(gen_multiply, 220),
(gen_unary, 200),
(gen_refusal_single, 260),
(gen_poisoned_matrix_question, 130),
(gen_poisoned_refusal, 130),
(gen_poisoned_concept_question, 60),
(gen_concept, 60),
]
for fn, count in generators:
made = 0
attempts = 0
while made < count and attempts < count * 4:
attempts += 1
ex = fn(rng)
if ex is not None:
examples.append(ex)
made += 1
print(f"{fn.__name__}: {made}")
rng.shuffle(examples)
n_valid = max(1, len(examples) // 10)
valid, train = examples[:n_valid], examples[n_valid:]
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
for name, rows in (("train.jsonl", train), ("valid.jsonl", valid)):
with (OUTPUT_DIR / name).open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"\nWrote {len(train)} train / {len(valid)} valid to {OUTPUT_DIR}")
if __name__ == "__main__":
main()
|