atakan Claude Fable 5 commited on
Commit
48ee375
·
1 Parent(s): 16e21ec

fix: Enforce parameter provenance, fix rendering/citations, add tools, harden agent loop

Browse files

Reliability mechanism (the big one):
- Orchestrator now refuses any tool call whose matrix arguments cannot be
traced to the user's messages or a prior tool result (FabricatedParameter):
prompting could not stop the model from inventing B/Q/R, code now does.
- Per-tool call cap (2/turn) stops reworded-search budget exhaustion.
- Truncated tool-call JSON (dropped closing brace) is repaired instead of
silently producing an empty answer; orphaned <tool_call> tags stripped.
- Failed turns fall back to a direct no-tools answer, never a canned line.

Tools:
- New matrix_arithmetic (multiply/inverse/det/rank/eig/...): basic linear
algebra is a verified tool call, not freehand model code.
- New nyquist_analysis (Z=N+P), root_locus_analysis (asymptotes, on-locus
breakaway points, critical gain), simulate_state_feedback_response
(closed loop A-BK formed internally; ends hand-expanded coefficients).
- place_state_feedback accepts complex-conjugate poles ([re, im] pairs);
dtype=float truncation bug fixed.
- Registry: stringified-array arguments coerced before schema validation;
all float results rounded to 6 significant figures.
- Registered tools are callable by name inside execute_python_code.
- plot_math_expression rejects complex expressions instead of plotting
garbage; simulate_step_response finally emits plot_path (plots never
reached the UI before).

Rendering & citations:
- Double-backslash LaTeX repair extended to delimiters (\\| \\{ \\() while
preserving real matrix row breaks; system prompt de-doubled.
- RAG citations use cleaned source names (published works by author/title,
personal lecture notes labeled generically) -- raw filenames never shown.

RAG:
- Process-wide shared index: uploads are searchable immediately, no restart.
- Source-aware re-ranking ("what does Nise say" surfaces Nise, 1/5 -> 4/4).
- Grounding block enlarged (4 passages, 900 chars) with trust-the-passage
and citation-format instructions.

Web UI:
- Deleting a non-active chat no longer wipes the active thread mid-stream.
- Animated working indicator; font-size preference now scales code blocks,
thought text, and plot captions.

Infra:
- MLX thread-affinity fix: all inference on one dedicated executor thread
(fixes "There is no Stream(cpu, 0) in current thread" on every request).
- Broader multi-domain system prompt (aerospace/automotive/robotics/
automation/power) with answer-shape routing rules.

Scripts:
- test_rag_knowledge.py: 25-query multi-domain retrieval suite (25/25).
- eval_answer_quality.py: 18-question full-agent quality gate.
- generate_behavior_sft_dataset.py + config: behavioral SFT round v1
(trained; failed A/B -- reduced tool usage -- so NOT deployed; kept for
the next data-mixture iteration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

app.py CHANGED
@@ -2,12 +2,14 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import json
6
  import os
 
7
  import shutil
8
  import sys
9
- import threading
10
  import time
 
11
  from contextlib import asynccontextmanager
12
  from pathlib import Path
13
  from typing import Any
@@ -27,12 +29,24 @@ if str(PROJECT_ROOT) not in sys.path:
27
  from controlai_agent.orchestrator import ControlAIAgent
28
  from controlai_rag.chunker import chunk_document
29
  from controlai_rag.document_loader import load_single_file
30
- from controlai_rag.index import ControlRAGIndex
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  @asynccontextmanager
33
  async def lifespan(app: FastAPI):
34
  print("Pre-loading ControlAI Core Engine on startup...")
35
- get_agent()
36
  print("ControlAI Core Engine is online and ready for traffic.")
37
  yield
38
 
@@ -64,9 +78,6 @@ if STATIC_DIR.exists():
64
 
65
  # Initialize Agent
66
  agent_instance: ControlAIAgent | None = None
67
- # llama.cpp's Llama object is not safe for concurrent generation calls from
68
- # multiple threads; serialize every request through the single model instance.
69
- inference_lock = threading.Lock()
70
 
71
 
72
  def get_agent() -> ControlAIAgent:
@@ -179,8 +190,10 @@ async def upload_document(file: UploadFile = File(...)) -> dict[str, Any]:
179
  chunks = chunk_document(p)
180
  new_chunks.extend(chunks)
181
 
182
- index_dir = PROJECT_ROOT / "data" / "rag_index"
183
- index = ControlRAGIndex(index_dir)
 
 
184
  index.add_chunks(new_chunks)
185
 
186
  return {
@@ -202,13 +215,33 @@ async def chat_stream_endpoint(req: ChatRequest):
202
  if not req.message.strip():
203
  raise HTTPException(status_code=400, detail="Message cannot be empty")
204
 
205
- def event_generator():
 
 
 
 
 
 
 
 
206
  try:
207
- with inference_lock:
208
- for event in _run_stream_on_gpu(req.message.strip(), req.history):
209
- yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
210
  except Exception as exc:
211
- yield f"data: {json.dumps({'type': 'error', 'error': str(exc)}, ensure_ascii=False)}\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
  return StreamingResponse(
214
  event_generator(),
@@ -228,8 +261,8 @@ async def chat_endpoint(req: ChatRequest) -> ChatResponse:
228
 
229
  t0 = time.time()
230
  try:
231
- with inference_lock:
232
- result = _run_on_gpu(req.message.strip(), req.history)
233
  elapsed = time.time() - t0
234
 
235
  # Collect tool traces
 
2
 
3
  from __future__ import annotations
4
 
5
+ import asyncio
6
  import json
7
  import os
8
+ import queue
9
  import shutil
10
  import sys
 
11
  import time
12
+ from concurrent.futures import ThreadPoolExecutor
13
  from contextlib import asynccontextmanager
14
  from pathlib import Path
15
  from typing import Any
 
29
  from controlai_agent.orchestrator import ControlAIAgent
30
  from controlai_rag.chunker import chunk_document
31
  from controlai_rag.document_loader import load_single_file
32
+ from controlai_rag.index import get_shared_index
33
+
34
+ # MLX keeps its compute stream in thread-local state: the model must be
35
+ # loaded on the exact same OS thread that later runs generation, or MLX
36
+ # raises "There is no Stream(cpu, 0) in current thread." Starlette's default
37
+ # StreamingResponse threadpool picks a different worker thread per request,
38
+ # which breaks that invariant. Routing every agent call through this single
39
+ # dedicated worker thread keeps MLX on one consistent thread for the whole
40
+ # process lifetime, and -- as a bonus -- a max_workers=1 executor naturally
41
+ # serializes every request through the one model instance (llama.cpp's Llama
42
+ # object also isn't safe for concurrent calls from multiple threads).
43
+ inference_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="controlai-inference")
44
+
45
 
46
  @asynccontextmanager
47
  async def lifespan(app: FastAPI):
48
  print("Pre-loading ControlAI Core Engine on startup...")
49
+ await asyncio.get_event_loop().run_in_executor(inference_executor, get_agent)
50
  print("ControlAI Core Engine is online and ready for traffic.")
51
  yield
52
 
 
78
 
79
  # Initialize Agent
80
  agent_instance: ControlAIAgent | None = None
 
 
 
81
 
82
 
83
  def get_agent() -> ControlAIAgent:
 
190
  chunks = chunk_document(p)
191
  new_chunks.extend(chunks)
192
 
193
+ # Mutating the shared index makes the upload live for the running
194
+ # agent immediately -- a fresh instance would only persist to disk and
195
+ # stay invisible until restart.
196
+ index = get_shared_index()
197
  index.add_chunks(new_chunks)
198
 
199
  return {
 
215
  if not req.message.strip():
216
  raise HTTPException(status_code=400, detail="Message cannot be empty")
217
 
218
+ message = req.message.strip()
219
+ history = req.history
220
+ event_queue: queue.Queue = queue.Queue()
221
+ _DONE = object()
222
+
223
+ def _produce() -> None:
224
+ # Runs entirely on inference_executor's single dedicated thread --
225
+ # the same thread the model was loaded on -- so MLX's thread-local
226
+ # stream stays valid.
227
  try:
228
+ for event in _run_stream_on_gpu(message, history):
229
+ event_queue.put(event)
 
230
  except Exception as exc:
231
+ event_queue.put({"type": "error", "error": str(exc)})
232
+ finally:
233
+ event_queue.put(_DONE)
234
+
235
+ async def event_generator():
236
+ loop = asyncio.get_event_loop()
237
+ loop.run_in_executor(inference_executor, _produce)
238
+ while True:
239
+ # Draining the queue never touches MLX, so this can safely run on
240
+ # the default threadpool.
241
+ event = await loop.run_in_executor(None, event_queue.get)
242
+ if event is _DONE:
243
+ break
244
+ yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
245
 
246
  return StreamingResponse(
247
  event_generator(),
 
261
 
262
  t0 = time.time()
263
  try:
264
+ loop = asyncio.get_event_loop()
265
+ result = await loop.run_in_executor(inference_executor, _run_on_gpu, req.message.strip(), req.history)
266
  elapsed = time.time() - t0
267
 
268
  # Collect tool traces
configs/lora_controlai_behavior_v1.yaml ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ControlAI Behavior v1: fix tool routing, missing-parameter refusal, and
2
+ # multi-turn faithfulness -- resumed from the deployed sft_v2 adapter.
3
+ #
4
+ # Why this round exists: the deployed adapter was trained exclusively on
5
+ # fully-specified control problems, so it (a) routed plain matrix questions
6
+ # to control-design tools, (b) fabricated missing B/Q/R instead of asking,
7
+ # and (c) reused numbers from previous unrelated problems in multi-turn
8
+ # chats. data/training/behavior_mix_v1 is 35% new verified behavioral
9
+ # trajectories targeting exactly those failures + 65% replay of sft_v2 and
10
+ # agent_sft_v1 so existing skills are retained.
11
+ model: mlx-community/Qwen3-4B-Instruct-2507-4bit
12
+ train: true
13
+ fine_tune_type: lora
14
+ data: data/training/behavior_mix_v1
15
+ seed: 20260819
16
+
17
+ resume_adapter_file: adapters/controlai_qwen3_4b_sft_v2/adapters.safetensors
18
+
19
+ mask_prompt: true
20
+
21
+ num_layers: 36
22
+ batch_size: 1
23
+ grad_accumulation_steps: 4
24
+ iters: 1600
25
+ max_seq_length: 2048
26
+ grad_checkpoint: false
27
+ clear_cache_threshold: 12000000000
28
+
29
+ optimizer: adamw
30
+ optimizer_config:
31
+ adamw:
32
+ weight_decay: 0.01
33
+ # Half the v2 learning rate: this is a refinement pass on an already-trained
34
+ # adapter, not training from scratch.
35
+ learning_rate: 2.0e-6
36
+ lr_schedule:
37
+ name: cosine_decay
38
+ # 1600 iters / accumulation 4 = 400 optimizer steps
39
+ arguments: [2.0e-6, 380, 2.0e-7]
40
+ warmup: 20
41
+ warmup_init: 2.0e-7
42
+
43
+ val_batches: -1
44
+ steps_per_report: 25
45
+ steps_per_eval: 400
46
+ save_every: 400
47
+ adapter_path: adapters/controlai_qwen3_4b_behavior_v1
48
+
49
+ lora_parameters:
50
+ rank: 16
51
+ dropout: 0.05
52
+ scale: 32.0
53
+ keys:
54
+ - self_attn.q_proj
55
+ - self_attn.k_proj
56
+ - self_attn.v_proj
57
+ - self_attn.o_proj
58
+ - mlp.gate_proj
59
+ - mlp.up_proj
60
+ - mlp.down_proj
controlai_agent/orchestrator.py CHANGED
@@ -34,7 +34,7 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
34
  from controlai_agent.prompts import CONTROLAI_SYSTEM_PROMPT
35
  from controlai_agent.registry import ToolRegistry, registry
36
  import controlai_agent.tools # noqa: F401 (ensure all tools are registered)
37
- from controlai_rag.index import ControlRAGIndex
38
 
39
 
40
  @dataclass
@@ -71,6 +71,42 @@ def fix_space_separated_arrays(json_str: str) -> str:
71
  return re.sub(r"\[\s*([0-9eE\.\-+\s]+?)\s*\]", _fix_brackets, json_str)
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def parse_flexible_json(raw_str: str) -> dict[str, Any] | None:
75
  """Parse JSON with fallback to escape sanitization, array fixing, and non-strict control characters."""
76
  raw_str = raw_str.strip()
@@ -97,9 +133,47 @@ def parse_flexible_json(raw_str: str) -> dict[str, Any] | None:
97
  except Exception:
98
  pass
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  return None
101
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  def _extract_tool_calls(text: str) -> tuple[list[dict[str, Any]], str]:
104
  """Parse tool calls from <tool_call>, markdown code blocks, or raw JSON robustly."""
105
  calls: list[dict[str, Any]] = []
@@ -129,12 +203,222 @@ def _extract_tool_calls(text: str) -> tuple[list[dict[str, Any]], str]:
129
  cleaned = re.sub(r"<tool_call>[\s\S]*?</tool_call>", "", text, flags=re.DOTALL)
130
  cleaned = re.sub(r"```(?:json)?\s*\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*```", "", cleaned)
131
  cleaned = re.sub(r"\{\s*[\"']name[\"']\s*:\s*[\"'][a-zA-Z0-9_]+[\"'][\s\S]*\}", "", cleaned)
 
 
 
 
132
  cleaned = cleaned.strip()
 
133
  return calls, cleaned
134
 
135
 
136
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  class ControlAIAgent:
140
  """Universal Control Engineering Agent supporting GGUF, Ollama C++, Apple MLX, and PyTorch."""
@@ -144,7 +428,7 @@ class ControlAIAgent:
144
  model_path: str = "mlx-community/Qwen3-4B-Instruct-2507-4bit",
145
  adapter_path: str | None = None,
146
  tool_registry: ToolRegistry = registry,
147
- max_tool_steps: int = 3,
148
  ) -> None:
149
  self.model_path = model_path
150
 
@@ -253,7 +537,7 @@ class ControlAIAgent:
253
 
254
  # Initialize local offline RAG index
255
  try:
256
- self.rag_index = ControlRAGIndex()
257
  except Exception:
258
  self.rag_index = None
259
 
@@ -316,23 +600,101 @@ class ControlAIAgent:
316
  return base_instruction
317
 
318
  try:
319
- rag_hits = self.rag_index.search(user_prompt, top_k=3)
320
  high_rel = [h for h in rag_hits if h.get("score", 0) > 2.5]
321
  if not high_rel:
322
  return base_instruction
323
 
324
  ref_texts = []
325
- for h in high_rel[:2]:
326
- fname = h.get("filename", "Reference")
 
327
  page = h.get("page")
328
- page_str = f" (p. {page})" if page else ""
329
- clean_chunk = h.get("text", "")[:400].strip()
330
- ref_texts.append(f"[{fname}{page_str}]:\n{clean_chunk}")
331
-
332
- return base_instruction + "\n\n### Grounded Reference Context from Canonical Control Literature:\n" + "\n\n".join(ref_texts)
 
 
 
 
 
 
 
 
 
 
 
333
  except Exception:
334
  return base_instruction
335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  def run(
337
  self,
338
  user_prompt: str,
@@ -360,6 +722,7 @@ class ControlAIAgent:
360
  traces: list[ToolExecutionTrace] = []
361
  plots: list[str] = []
362
  called_signatures: set[str] = set()
 
363
 
364
  for step in range(1, self.max_tool_steps + 1):
365
  rendered_prompt = self.hf_tokenizer.apply_chat_template(
@@ -374,9 +737,16 @@ class ControlAIAgent:
374
  tool_calls, pre_text = _extract_tool_calls(model_output)
375
 
376
  if not tool_calls:
377
- clean_output = re.sub(r"<tool_call>.*?</tool_call>", "", model_output, flags=re.DOTALL).strip()
 
 
 
 
 
 
 
378
  return AgentResult(
379
- final_response=clean_output,
380
  tool_traces=traces,
381
  total_steps=step,
382
  raw_messages=messages,
@@ -384,13 +754,22 @@ class ControlAIAgent:
384
  plots=plots,
385
  )
386
 
387
- # Check for loops
 
 
 
 
388
  new_calls = []
389
  for call in tool_calls:
390
- sig = f"{call.get('name')}:{json.dumps(call.get('arguments', {}), sort_keys=True)}"
391
- if sig not in called_signatures:
392
- called_signatures.add(sig)
393
- new_calls.append(call)
 
 
 
 
 
394
 
395
  if not new_calls:
396
  break
@@ -401,7 +780,7 @@ class ControlAIAgent:
401
  tool_name = call_data.get("name")
402
  tool_args = call_data.get("arguments", {})
403
 
404
- tool_result = self.registry.execute(tool_name, tool_args)
405
  traces.append(ToolExecutionTrace(tool_name=tool_name, arguments=tool_args, result=tool_result))
406
 
407
  if "plot_path" in tool_result:
@@ -416,6 +795,17 @@ class ControlAIAgent:
416
  })
417
 
418
  # Final synthesis after tool execution
 
 
 
 
 
 
 
 
 
 
 
419
  forced_prompt = self.hf_tokenizer.apply_chat_template(
420
  messages,
421
  tools=None,
@@ -425,8 +815,15 @@ class ControlAIAgent:
425
  final_output = self._generate(forced_prompt, max_tokens=max_tokens_per_step)
426
 
427
  _, clean_final = _extract_tool_calls(final_output)
 
 
 
 
 
 
 
428
  return AgentResult(
429
- final_response=clean_final or final_output,
430
  tool_traces=traces,
431
  total_steps=len(traces) + 1,
432
  raw_messages=messages,
@@ -465,6 +862,7 @@ class ControlAIAgent:
465
  plots: list[str] = []
466
  thoughts: list[str] = []
467
  called_signatures: set[str] = set()
 
468
 
469
  # Dynamic thought generation - only show thoughts when tools or derivations occur
470
  for step in range(1, self.max_tool_steps + 1):
@@ -481,7 +879,9 @@ class ControlAIAgent:
481
 
482
  if not tool_calls:
483
  # Direct final answer without tools -> stream tokens directly
484
- clean_output = pre_text or model_output
 
 
485
  words = re.split(r"(\s+)", clean_output)
486
  for w in words:
487
  if w:
@@ -500,13 +900,22 @@ class ControlAIAgent:
500
  thoughts.append(pre_text)
501
  yield {"type": "thought", "content": pre_text}
502
 
503
- # Filter out duplicate loops
 
 
 
 
504
  new_calls = []
505
  for call in tool_calls:
506
- sig = f"{call.get('name')}:{json.dumps(call.get('arguments', {}), sort_keys=True)}"
507
- if sig not in called_signatures:
508
- called_signatures.add(sig)
509
- new_calls.append(call)
 
 
 
 
 
510
 
511
  if not new_calls:
512
  break
@@ -522,7 +931,7 @@ class ControlAIAgent:
522
  yield {"type": "thought", "content": t_start_msg}
523
  yield {"type": "tool_start", "tool": tool_name, "args": tool_args}
524
 
525
- tool_result = self.registry.execute(tool_name, tool_args)
526
  trace_item = {
527
  "tool": tool_name,
528
  "args": tool_args,
@@ -556,7 +965,14 @@ class ControlAIAgent:
556
 
557
  messages.append({
558
  "role": "user",
559
- "content": "Synthesize the simulation and calculation results above into a complete, thorough engineering explanation with LaTeX equations. Do not output any JSON or tool call tags.",
 
 
 
 
 
 
 
560
  })
561
 
562
  forced_prompt = self.hf_tokenizer.apply_chat_template(
@@ -574,7 +990,7 @@ class ControlAIAgent:
574
  for call_data in synth_tool_calls:
575
  tool_name = call_data.get("name")
576
  tool_args = call_data.get("arguments", {})
577
- t_res = self.registry.execute(tool_name, tool_args)
578
  if "plot_path" in t_res:
579
  p_path = Path(t_res["plot_path"])
580
  if p_path.exists():
@@ -588,7 +1004,11 @@ class ControlAIAgent:
588
  final_output = self._generate(re_prompt, max_tokens=max_tokens_per_step)
589
  _, clean_synth = _extract_tool_calls(final_output)
590
 
591
- final_text = clean_synth or final_output
 
 
 
 
592
  # Strip any lingering raw json
593
  final_text = re.sub(r"\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*\}", "", final_text).strip()
594
  if not final_text:
 
34
  from controlai_agent.prompts import CONTROLAI_SYSTEM_PROMPT
35
  from controlai_agent.registry import ToolRegistry, registry
36
  import controlai_agent.tools # noqa: F401 (ensure all tools are registered)
37
+ from controlai_rag.index import get_shared_index
38
 
39
 
40
  @dataclass
 
71
  return re.sub(r"\[\s*([0-9eE\.\-+\s]+?)\s*\]", _fix_brackets, json_str)
72
 
73
 
74
+ def close_unbalanced_json(raw: str) -> str:
75
+ """Append the closing braces/brackets a truncated JSON object is missing.
76
+
77
+ Small quantized models routinely drop the final `}` of a tool call (emitting
78
+ `{"name": ..., "arguments": {...}` with the outer object left open). Without
79
+ repair the call fails to parse, the tool never runs, and the turn collapses
80
+ into an empty answer -- so balance the delimiters and let it parse.
81
+ """
82
+ stack: list[str] = []
83
+ in_string = False
84
+ escaped = False
85
+ for ch in raw:
86
+ if in_string:
87
+ if escaped:
88
+ escaped = False
89
+ elif ch == "\\":
90
+ escaped = True
91
+ elif ch == '"':
92
+ in_string = False
93
+ continue
94
+ if ch == '"':
95
+ in_string = True
96
+ elif ch in "{[":
97
+ stack.append(ch)
98
+ elif ch in "}]":
99
+ if stack and ((ch == "}" and stack[-1] == "{") or (ch == "]" and stack[-1] == "[")):
100
+ stack.pop()
101
+
102
+ repaired = raw
103
+ if in_string:
104
+ repaired += '"'
105
+ for opener in reversed(stack):
106
+ repaired += "}" if opener == "{" else "]"
107
+ return repaired
108
+
109
+
110
  def parse_flexible_json(raw_str: str) -> dict[str, Any] | None:
111
  """Parse JSON with fallback to escape sanitization, array fixing, and non-strict control characters."""
112
  raw_str = raw_str.strip()
 
133
  except Exception:
134
  pass
135
 
136
+ # Last resort: the object is well-formed but truncated (a dropped closing
137
+ # brace), so balance the delimiters and retry each variant.
138
+ for candidate in (
139
+ raw_str,
140
+ sanitize_json_escapes(raw_str),
141
+ fix_space_separated_arrays(sanitize_json_escapes(raw_str)),
142
+ ):
143
+ try:
144
+ obj = json.loads(close_unbalanced_json(candidate), strict=False)
145
+ if isinstance(obj, dict):
146
+ return obj
147
+ except Exception:
148
+ continue
149
+
150
  return None
151
 
152
 
153
+ # A doubled backslash is an accidental JSON-style escape when it precedes:
154
+ # - a command name \\zeta \\frac \\sum
155
+ # - a delimiter escape \\| \\{ \\} \\( \\) \\]
156
+ # - \\[ that does NOT begin a spacing argument such as a genuine "\\[2pt]"
157
+ # A real LaTeX row break inside matrix/aligned is followed by whitespace, a
158
+ # newline, or a digit-led spacing option -- never by any of the above -- so
159
+ # these rewrites leave legitimate line breaks untouched.
160
+ _LATEX_DOUBLE_BACKSLASH_RE = re.compile(r"\\\\(?=[a-zA-Z|{}()\]])|\\\\(?=\[\s*[^\d\s])")
161
+
162
+
163
+ def _fix_doubled_latex_backslashes(text: str) -> str:
164
+ """Collapse an accidental JSON-escape-style double backslash before a LaTeX
165
+ command or delimiter (\\\\zeta, \\\\|) down to the single backslash KaTeX
166
+ expects (\\zeta, \\|).
167
+
168
+ The base model was heavily trained on JSON-argument function calling, where
169
+ a literal backslash must be written as `\\\\` inside a JSON string. That
170
+ habit leaks into plain-text math even outside of a tool call. Left alone,
171
+ `\\\\|x - x_f\\\\|^2` renders as a line break followed by a stray pipe,
172
+ tearing a norm across two lines instead of drawing it.
173
+ """
174
+ return _LATEX_DOUBLE_BACKSLASH_RE.sub(lambda m: "\\", text)
175
+
176
+
177
  def _extract_tool_calls(text: str) -> tuple[list[dict[str, Any]], str]:
178
  """Parse tool calls from <tool_call>, markdown code blocks, or raw JSON robustly."""
179
  calls: list[dict[str, Any]] = []
 
203
  cleaned = re.sub(r"<tool_call>[\s\S]*?</tool_call>", "", text, flags=re.DOTALL)
204
  cleaned = re.sub(r"```(?:json)?\s*\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*```", "", cleaned)
205
  cleaned = re.sub(r"\{\s*[\"']name[\"']\s*:\s*[\"'][a-zA-Z0-9_]+[\"'][\s\S]*\}", "", cleaned)
206
+ # An orphaned <tool_call> tag with no matching close (the model started a
207
+ # call, then abandoned it mid-generation for plain text) survives the
208
+ # paired regex above -- strip any leftover tag so it never reaches the UI.
209
+ cleaned = re.sub(r"</?tool_call>", "", cleaned)
210
  cleaned = cleaned.strip()
211
+ cleaned = _fix_doubled_latex_backslashes(cleaned)
212
  return calls, cleaned
213
 
214
 
215
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
216
 
217
+ # How many times a single tool may be invoked within one user turn. Two allows
218
+ # a legitimate retry with corrected arguments after an error, while stopping
219
+ # the model from spending its whole step budget re-running the same lookup.
220
+ MAX_CALLS_PER_TOOL = 2
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Parameter provenance enforcement
225
+ #
226
+ # Prompt rules against inventing parameters demonstrably do not hold on a 4B
227
+ # model: given "A times A" after an earlier LQR conversation, it fabricated a
228
+ # brand-new B, resized Q, and ran a confident LQR synthesis -- three separate
229
+ # prompt formulations failed to stop it. So faithfulness is enforced in code:
230
+ # every 2D matrix handed to a tool must be traceable to the user's messages or
231
+ # a prior tool result in this conversation, or the call is refused before it
232
+ # executes and the model is told to ask the user for the missing value.
233
+ # ---------------------------------------------------------------------------
234
+
235
+ # Parameters exempt from the guard:
236
+ # - C/D: conventional output-selector / feedthrough matrices (entries 0/1),
237
+ # routinely and legitimately chosen by the designer, and harmless.
238
+ # - desired_poles: pole locations are design choices derived from specs
239
+ # ("settling time under 2s"), not data the user must dictate literally.
240
+ _PROVENANCE_EXEMPT_PARAMS = {"C", "D", "desired_poles"}
241
+
242
+ _NUMBER_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?")
243
+
244
+
245
+ def _extract_arrays_from_text(text: str) -> list[list]:
246
+ """Find every parseable bracketed numeric array (1D or 2D) in free text."""
247
+ arrays: list[list] = []
248
+ n = len(text)
249
+ i = 0
250
+ while i < n:
251
+ if text[i] != "[":
252
+ i += 1
253
+ continue
254
+ depth = 0
255
+ j = i
256
+ while j < n:
257
+ if text[j] == "[":
258
+ depth += 1
259
+ elif text[j] == "]":
260
+ depth -= 1
261
+ if depth == 0:
262
+ break
263
+ j += 1
264
+ if depth != 0:
265
+ i += 1
266
+ continue
267
+ candidate = text[i : j + 1]
268
+ # Third variant: users mix separators freely ("[3 0, 1]"), which the
269
+ # comma-only fixer can't handle -- insert a comma between any two
270
+ # adjacent number tokens regardless of other separators present.
271
+ mixed_fixed = re.sub(r"(?<=[\d.])\s+(?=[-\d.])", ", ", candidate)
272
+ for variant in (candidate, fix_space_separated_arrays(candidate), mixed_fixed):
273
+ try:
274
+ parsed = json.loads(variant)
275
+ except json.JSONDecodeError:
276
+ continue
277
+ if isinstance(parsed, list) and parsed:
278
+ arrays.append(parsed)
279
+ break
280
+ # Only skip past this bracket char, not the whole span: inner arrays
281
+ # of a 2D matrix should also be collected individually (rows are
282
+ # legitimate 1D vectors in their own right).
283
+ i += 1
284
+ return arrays
285
+
286
+
287
+ def _collect_numeric_leaves(obj: Any, arrays: list[list], numbers: set[float]) -> None:
288
+ """Harvest nested numeric lists and scalars from a parsed JSON object."""
289
+ if isinstance(obj, bool):
290
+ return
291
+ if isinstance(obj, (int, float)):
292
+ numbers.add(float(obj))
293
+ return
294
+ if isinstance(obj, list):
295
+ if obj and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in obj):
296
+ arrays.append(list(obj))
297
+ elif obj and all(isinstance(v, list) for v in obj):
298
+ arrays.append(obj)
299
+ for v in obj:
300
+ _collect_numeric_leaves(v, arrays, numbers)
301
+ return
302
+ if isinstance(obj, dict):
303
+ for v in obj.values():
304
+ _collect_numeric_leaves(v, arrays, numbers)
305
+
306
+
307
+ def _as_2d_float_array(value: Any):
308
+ """Return value as a 2D float ndarray, or None if it isn't one."""
309
+ import numpy as _np
310
+
311
+ try:
312
+ arr = _np.array(value, dtype=float)
313
+ except (TypeError, ValueError):
314
+ return None
315
+ if arr.ndim == 1 and arr.size > 0:
316
+ arr = arr.reshape(1, -1)
317
+ if arr.ndim != 2 or arr.size == 0:
318
+ return None
319
+ return arr
320
+
321
+
322
+ class ParameterProvenance:
323
+ """Everything numeric the conversation has actually provided so far."""
324
+
325
+ def __init__(self, messages: list[dict[str, Any]]) -> None:
326
+ import numpy as _np
327
+
328
+ self._np = _np
329
+ self.arrays: list[Any] = []
330
+ self.current_numbers: set[float] = set()
331
+ all_user_texts: list[str] = []
332
+ current_user_text = ""
333
+
334
+ for msg in messages:
335
+ role = msg.get("role")
336
+ content = msg.get("content", "")
337
+ if role == "user" and isinstance(content, str):
338
+ all_user_texts.append(content)
339
+ current_user_text = content
340
+ elif role == "tool" and isinstance(content, str):
341
+ try:
342
+ parsed = json.loads(content)
343
+ except json.JSONDecodeError:
344
+ continue
345
+ found_arrays: list[list] = []
346
+ found_numbers: set[float] = set()
347
+ _collect_numeric_leaves(parsed, found_arrays, found_numbers)
348
+ for a in found_arrays:
349
+ arr = _as_2d_float_array(a)
350
+ if arr is not None:
351
+ self.arrays.append(arr)
352
+ # Scalars computed by tools (gains, margins) are legitimate
353
+ # inputs for later steps.
354
+ self.current_numbers |= found_numbers
355
+
356
+ for text in all_user_texts:
357
+ for a in _extract_arrays_from_text(text):
358
+ arr = _as_2d_float_array(a)
359
+ if arr is not None:
360
+ self.arrays.append(arr)
361
+ # Loose scalars only count from the CURRENT user message: numbers from
362
+ # an earlier, different problem are exactly what must not silently
363
+ # seed a new Q or R.
364
+ for m in _NUMBER_RE.finditer(current_user_text):
365
+ try:
366
+ self.current_numbers.add(float(m.group()))
367
+ except ValueError:
368
+ pass
369
+
370
+ def _matches_known_array(self, arr) -> bool:
371
+ np_ = self._np
372
+ for known in self.arrays:
373
+ for cand in (known, known.T):
374
+ if cand.shape == arr.shape and np_.allclose(cand, arr, rtol=1e-6, atol=1e-9):
375
+ return True
376
+ return False
377
+
378
+ def _number_provided(self, x: float) -> bool:
379
+ if x in (0.0, 1.0, -1.0):
380
+ return True
381
+ return any(abs(x - n) <= 1e-9 * max(1.0, abs(n)) for n in self.current_numbers)
382
+
383
+ def verify(self, value: Any) -> bool:
384
+ """True if this matrix is traceable to the conversation."""
385
+ np_ = self._np
386
+ arr = _as_2d_float_array(value)
387
+ if arr is None:
388
+ return True # not a matrix -- out of scope for this guard
389
+
390
+ if self._matches_known_array(arr):
391
+ return True
392
+
393
+ # 1x1 "matrix" wrapping a scalar the user stated (R=1 -> [[1]]).
394
+ if arr.shape == (1, 1):
395
+ return self._number_provided(float(arr[0, 0]))
396
+
397
+ # Identity / zero matrices are structural, not data.
398
+ if arr.shape[0] == arr.shape[1]:
399
+ if np_.allclose(arr, np_.eye(arr.shape[0])) or np_.allclose(arr, 0.0):
400
+ return True
401
+ # diag(...) built from numbers in the current message, e.g. the
402
+ # user wrote "Q=diag([10, 1])" in prose rather than as an array.
403
+ if np_.allclose(arr, np_.diag(np_.diag(arr))):
404
+ return all(self._number_provided(float(d)) for d in np_.diag(arr))
405
+
406
+ return False
407
+
408
+
409
+ def _check_parameter_provenance(
410
+ tool_args: dict[str, Any], messages: list[dict[str, Any]]
411
+ ) -> tuple[str, Any] | None:
412
+ """Return (param_name, value) for the first fabricated matrix, else None."""
413
+ provenance = ParameterProvenance(messages)
414
+ for param, value in tool_args.items():
415
+ if param in _PROVENANCE_EXEMPT_PARAMS:
416
+ continue
417
+ if isinstance(value, list) and value and isinstance(value[0], list):
418
+ if not provenance.verify(value):
419
+ return param, value
420
+ return None
421
+
422
 
423
  class ControlAIAgent:
424
  """Universal Control Engineering Agent supporting GGUF, Ollama C++, Apple MLX, and PyTorch."""
 
428
  model_path: str = "mlx-community/Qwen3-4B-Instruct-2507-4bit",
429
  adapter_path: str | None = None,
430
  tool_registry: ToolRegistry = registry,
431
+ max_tool_steps: int = 4,
432
  ) -> None:
433
  self.model_path = model_path
434
 
 
537
 
538
  # Initialize local offline RAG index
539
  try:
540
+ self.rag_index = get_shared_index()
541
  except Exception:
542
  self.rag_index = None
543
 
 
600
  return base_instruction
601
 
602
  try:
603
+ rag_hits = self.rag_index.search(user_prompt, top_k=6)
604
  high_rel = [h for h in rag_hits if h.get("score", 0) > 2.5]
605
  if not high_rel:
606
  return base_instruction
607
 
608
  ref_texts = []
609
+ for h in high_rel[:4]:
610
+ # Cite the cleaned display name, never the raw indexed filename.
611
+ label = h.get("source_name") or h.get("filename", "Reference")
612
  page = h.get("page")
613
+ page_str = f", p. {page}" if page else ""
614
+ clean_chunk = " ".join(h.get("text", "").split())[:900].strip()
615
+ ref_texts.append(f"[{label}{page_str}]\n{clean_chunk}")
616
+
617
+ return (
618
+ base_instruction
619
+ + "\n\n### Grounded Reference Context from Canonical Control Literature:\n"
620
+ + "\n\n".join(ref_texts)
621
+ + "\n\nThese passages were retrieved from the local library for THIS question. When the "
622
+ "question asks what a specific author or textbook says, answer directly from these "
623
+ "passages -- that is already the reference lookup, so do not call a numeric solver tool "
624
+ "to answer a conceptual question. Where a passage conflicts with your own recollection, "
625
+ "TRUST THE PASSAGE. Cite using exactly the bracketed label shown above (for example "
626
+ "[Nise, Control Systems Engineering, p. 583]); never invent a citation and never print a "
627
+ "raw filename, file extension, or course code."
628
+ )
629
  except Exception:
630
  return base_instruction
631
 
632
+ def _direct_answer(
633
+ self,
634
+ user_prompt: str,
635
+ system_instruction: str,
636
+ history: list[dict[str, Any]] | None = None,
637
+ max_tokens: int = 1400,
638
+ ) -> str:
639
+ """Answer the question with no tools exposed at all.
640
+
641
+ This is the recovery path for when the tool loop fails to produce usable
642
+ prose -- the model spent its steps on tool calls, or its synthesis turn
643
+ came back empty or as yet another tool call. Re-asking with tools=None
644
+ and without the tool-result transcript removes whatever was derailing it
645
+ (and shortens the prompt considerably), which reliably yields a real
646
+ answer instead of a canned "analysis complete" placeholder. The grounded
647
+ system instruction is kept, so retrieved reference passages are still
648
+ available to answer from.
649
+ """
650
+ messages: list[dict[str, Any]] = []
651
+ if system_instruction:
652
+ messages.append({"role": "system", "content": system_instruction})
653
+ if history:
654
+ for item in history:
655
+ r = item.get("role")
656
+ c = item.get("content")
657
+ if r in ("user", "assistant") and c:
658
+ messages.append({"role": r, "content": c})
659
+ messages.append({"role": "user", "content": user_prompt})
660
+
661
+ try:
662
+ rendered = self.hf_tokenizer.apply_chat_template(
663
+ messages, tools=None, tokenize=False, add_generation_prompt=True
664
+ )
665
+ _, cleaned = _extract_tool_calls(self._generate(rendered, max_tokens=max_tokens))
666
+ return cleaned
667
+ except Exception:
668
+ return ""
669
+
670
+ def _execute_with_provenance(
671
+ self,
672
+ tool_name: str,
673
+ tool_args: dict[str, Any],
674
+ messages: list[dict[str, Any]],
675
+ ) -> dict[str, Any]:
676
+ """Run a tool only after every matrix argument is traced to the
677
+ conversation; refuse fabricated inputs before they execute."""
678
+ try:
679
+ fabricated = _check_parameter_provenance(tool_args, messages)
680
+ except Exception:
681
+ fabricated = None # the guard must never take down a legitimate call
682
+ if fabricated is not None:
683
+ param, value = fabricated
684
+ return {
685
+ "status": "error",
686
+ "error_type": "FabricatedParameter",
687
+ "error": (
688
+ f"REFUSED: the matrix passed as '{param}' = {json.dumps(value)} was not provided by "
689
+ f"the user in this conversation and did not come from any prior tool result. Inventing "
690
+ f"parameter values is forbidden. Do NOT retry this tool with a different guessed "
691
+ f"'{param}'. In your final answer, tell the user that '{param}' is required for this "
692
+ f"computation and ask them to provide it, and answer whatever part of their question "
693
+ f"does not need it."
694
+ ),
695
+ }
696
+ return self.registry.execute(tool_name, tool_args)
697
+
698
  def run(
699
  self,
700
  user_prompt: str,
 
722
  traces: list[ToolExecutionTrace] = []
723
  plots: list[str] = []
724
  called_signatures: set[str] = set()
725
+ tool_call_counts: dict[str, int] = {}
726
 
727
  for step in range(1, self.max_tool_steps + 1):
728
  rendered_prompt = self.hf_tokenizer.apply_chat_template(
 
737
  tool_calls, pre_text = _extract_tool_calls(model_output)
738
 
739
  if not tool_calls:
740
+ # The model chose to stop calling tools but produced no usable
741
+ # text either (typically right after a tool error it has no
742
+ # good way to recover from in-context). Retry once with no
743
+ # tools and no error-laden transcript rather than returning
744
+ # nothing.
745
+ final_response = pre_text or self._direct_answer(user_prompt, effective_sys, history)
746
+ if not final_response:
747
+ final_response = "The computational analysis has been completed as detailed above."
748
  return AgentResult(
749
+ final_response=final_response,
750
  tool_traces=traces,
751
  total_steps=step,
752
  raw_messages=messages,
 
754
  plots=plots,
755
  )
756
 
757
+ # Drop repeats. An exact-signature check alone is not enough: the
758
+ # model will re-search with a lightly reworded query ("... MPC",
759
+ # "... MPC algorithm", "... MPC definition"), exhausting the step
760
+ # budget on near-identical lookups and leaving nothing for the
761
+ # answer. Cap how many times any single tool may run per turn.
762
  new_calls = []
763
  for call in tool_calls:
764
+ name = call.get("name")
765
+ sig = f"{name}:{json.dumps(call.get('arguments', {}), sort_keys=True)}"
766
+ if sig in called_signatures:
767
+ continue
768
+ if tool_call_counts.get(name, 0) >= MAX_CALLS_PER_TOOL:
769
+ continue
770
+ called_signatures.add(sig)
771
+ tool_call_counts[name] = tool_call_counts.get(name, 0) + 1
772
+ new_calls.append(call)
773
 
774
  if not new_calls:
775
  break
 
780
  tool_name = call_data.get("name")
781
  tool_args = call_data.get("arguments", {})
782
 
783
+ tool_result = self._execute_with_provenance(tool_name, tool_args, messages)
784
  traces.append(ToolExecutionTrace(tool_name=tool_name, arguments=tool_args, result=tool_result))
785
 
786
  if "plot_path" in tool_result:
 
795
  })
796
 
797
  # Final synthesis after tool execution
798
+ messages.append({
799
+ "role": "user",
800
+ "content": (
801
+ "Now answer the user's most recent question directly and completely, in LaTeX-formatted "
802
+ "prose. If the tool results above are relevant to that question, incorporate them; if the "
803
+ "question is conceptual, definitional, or about what a source says and the tool results "
804
+ "above don't actually address it, answer the question from your own knowledge instead of "
805
+ "describing the tool results. Do not call any more tools and do not output JSON or "
806
+ "tool-call tags -- write the final answer now."
807
+ ),
808
+ })
809
  forced_prompt = self.hf_tokenizer.apply_chat_template(
810
  messages,
811
  tools=None,
 
815
  final_output = self._generate(forced_prompt, max_tokens=max_tokens_per_step)
816
 
817
  _, clean_final = _extract_tool_calls(final_output)
818
+ # Never fall back to the raw final_output: if the model's closing turn
819
+ # degenerates into another (unparseable) tool-call attempt, clean_final
820
+ # is stripped down to "" and showing final_output would leak a raw
821
+ # <tool_call>{...}</tool_call> JSON blob straight into the chat.
822
+ final_response = clean_final or self._direct_answer(user_prompt, effective_sys, history)
823
+ if not final_response:
824
+ final_response = "The computational analysis has been completed as detailed above."
825
  return AgentResult(
826
+ final_response=final_response,
827
  tool_traces=traces,
828
  total_steps=len(traces) + 1,
829
  raw_messages=messages,
 
862
  plots: list[str] = []
863
  thoughts: list[str] = []
864
  called_signatures: set[str] = set()
865
+ tool_call_counts: dict[str, int] = {}
866
 
867
  # Dynamic thought generation - only show thoughts when tools or derivations occur
868
  for step in range(1, self.max_tool_steps + 1):
 
879
 
880
  if not tool_calls:
881
  # Direct final answer without tools -> stream tokens directly
882
+ clean_output = pre_text or self._direct_answer(user_prompt, effective_sys, history)
883
+ if not clean_output:
884
+ clean_output = "The computational analysis has been completed as detailed above."
885
  words = re.split(r"(\s+)", clean_output)
886
  for w in words:
887
  if w:
 
900
  thoughts.append(pre_text)
901
  yield {"type": "thought", "content": pre_text}
902
 
903
+ # Drop repeats. An exact-signature check alone is not enough: the
904
+ # model will re-search with a lightly reworded query ("... MPC",
905
+ # "... MPC algorithm", "... MPC definition"), exhausting the step
906
+ # budget on near-identical lookups and leaving nothing for the
907
+ # answer. Cap how many times any single tool may run per turn.
908
  new_calls = []
909
  for call in tool_calls:
910
+ name = call.get("name")
911
+ sig = f"{name}:{json.dumps(call.get('arguments', {}), sort_keys=True)}"
912
+ if sig in called_signatures:
913
+ continue
914
+ if tool_call_counts.get(name, 0) >= MAX_CALLS_PER_TOOL:
915
+ continue
916
+ called_signatures.add(sig)
917
+ tool_call_counts[name] = tool_call_counts.get(name, 0) + 1
918
+ new_calls.append(call)
919
 
920
  if not new_calls:
921
  break
 
931
  yield {"type": "thought", "content": t_start_msg}
932
  yield {"type": "tool_start", "tool": tool_name, "args": tool_args}
933
 
934
+ tool_result = self._execute_with_provenance(tool_name, tool_args, messages)
935
  trace_item = {
936
  "tool": tool_name,
937
  "args": tool_args,
 
965
 
966
  messages.append({
967
  "role": "user",
968
+ "content": (
969
+ "Now answer the user's most recent question directly and completely, in LaTeX-formatted "
970
+ "prose. If the tool results above are relevant to that question, incorporate them; if the "
971
+ "question is conceptual, definitional, or about what a source says and the tool results "
972
+ "above don't actually address it, answer the question from your own knowledge instead of "
973
+ "describing the tool results. Do not call any more tools and do not output JSON or "
974
+ "tool-call tags -- write the final answer now."
975
+ ),
976
  })
977
 
978
  forced_prompt = self.hf_tokenizer.apply_chat_template(
 
990
  for call_data in synth_tool_calls:
991
  tool_name = call_data.get("name")
992
  tool_args = call_data.get("arguments", {})
993
+ t_res = self._execute_with_provenance(tool_name, tool_args, messages)
994
  if "plot_path" in t_res:
995
  p_path = Path(t_res["plot_path"])
996
  if p_path.exists():
 
1004
  final_output = self._generate(re_prompt, max_tokens=max_tokens_per_step)
1005
  _, clean_synth = _extract_tool_calls(final_output)
1006
 
1007
+ # Never fall back to the raw final_output here: if the model's closing
1008
+ # turn degenerates into another (unparseable) tool-call attempt,
1009
+ # clean_synth is stripped down to "" and showing final_output would
1010
+ # leak a raw <tool_call>{...}</tool_call> JSON blob into the chat.
1011
+ final_text = clean_synth
1012
  # Strip any lingering raw json
1013
  final_text = re.sub(r"\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*\}", "", final_text).strip()
1014
  if not final_text:
controlai_agent/prompts.py CHANGED
@@ -1,20 +1,39 @@
1
- CONTROLAI_SYSTEM_PROMPT = r"""You are ControlAI, a premier AI research scientist and expert engineering agent specialized in control systems engineering, applied mathematics, robotics, and dynamical systems.
 
 
 
 
 
 
 
 
2
 
3
  ### 4-Stage Mathematical Reasoning & Proof Standard:
4
  When answering theoretical principles, derivations, proofs, comparisons, or limitation questions:
5
- 1. **Mathematical Context & System Class**: State the state space equations (e.g. $\\dot{x} = Ax + Bu, y = Cx + Du$ or $\\dot{x} = f(x) + g(x)u$), signal spaces ($\mathcal{L}_2, \mathcal{H}_\\infty$), domain definitions, and underlying assumptions.
6
  2. **Canonical Theorem / Analytical Principle**: State the governing theorem with exact mathematical rigor (e.g. PBH rank test, Doyle 1978 LQG robustness counterexample, Poisson Integral for RHP zeros, Small Gain vs Passivity, Lyapunov Invariance).
7
- 3. **Exact Derivation & Closed-Form Formulas**: Provide the full, exact mathematical relationship in pure LaTeX (e.g. both the standard linear approximation $\\zeta \\approx PM/100$ and the exact non-linear relationship $PM = \\arctan\\left(\\frac{2\\zeta}{\\sqrt{\\sqrt{1+4\\zeta^4}-2\\zeta^2}}\\right)$).
8
- 4. **Engineering Caveats & Breakdown Conditions**: Explicitly state where approximations break down (e.g. $PM > 60^\\circ$ or high-frequency non-dominant dynamics), numerical ill-conditioning (e.g. high-order Kalman matrices vs PBH), and physical conservation trade-offs (e.g. Bode sensitivity integral / waterbed effect).
9
 
10
  ### Core Deterministic Capabilities & Tool Calling:
11
- - Step Response Simulation: Use `simulate_step_response(numerator=[...], denominator=[...], sim_time=10.0)` for exact step response metrics and automatic plot generation.
12
- - Python Code Execution (`execute_python_code`): Write valid Python scripts using `scipy.signal`, `scipy.linalg`, `control` (`import control as ct`), `numpy`, `matplotlib.pyplot`. (Note: `T` in `signal.step` must be a 1D array like `np.linspace(...)`).
13
- - Deterministic Math Tools: Use `continuous_lqr`, `exact_zoh`, `stability_margins`, `plot_math_expression` for exact calculations.
 
 
 
 
 
 
 
 
 
 
14
 
15
  ### Strict Negative & Formatting Constraints:
16
  1. **ZERO EMOJIS**: NEVER use any emojis anywhere in your response (absolutely NO checkmarks, NO pins, NO graphs, NO rockets, NO lightbulbs).
17
  2. **ALWAYS USE DOLLAR DELIMITERS FOR MATH**: ALWAYS enclose EVERY mathematical formula, transfer function, variable, fraction, and Greek letter in dollar signs: `$ ... $` for inline math or `$$ ... $$` for centered equations. Example: `$$G(s) = \frac{1}{s(s+1)(s+2)}$$` and `$\omega \to \infty$`. NEVER write raw LaTeX keywords (`\frac`, `\omega`, `\to`) without `$` or `$$` delimiters!
 
18
  3. **NO RAW LATEX DOCUMENT TAGS**: NEVER output `\begin{figure}`, `\includegraphics`, `\caption`, `\centering`, `\section*`, or `\end{figure}`. Use standard markdown headers (e.g. `### Section Title`).
19
  4. **MANDATORY TOOL CALL FOR PLOTS**: When asked to plot, visualize, or simulate (Nyquist plot, Bode diagram, Root Locus, Step Response, Phase Portrait), NEVER hallucinate a fake image filename. You MUST explicitly call `execute_python_code` (using `control as ct` or `matplotlib.pyplot`) or `simulate_step_response` to generate and display the real plot!
20
  5. **Deterministic Grounding**: When a tool executes successfully, present the exact numerical results and generated plot.
 
1
+ CONTROLAI_SYSTEM_PROMPT = r"""You are ControlAI, a premier AI research scientist and expert engineering agent for control systems engineering in ALL of its application domains -- aerospace and flight control, automotive and vehicle dynamics, robotics and motion control, industrial automation and PLC/process control, mechatronics, power and energy systems, marine and space vehicles -- together with the underlying applied mathematics, system identification, estimation, and dynamical systems theory.
2
+
3
+ Treat every control question as in scope regardless of the industry it comes from, and answer it in the vocabulary of that domain: an autopilot question deserves flight-dynamics language (short-period mode, actuator rate limits, gain scheduling on airspeed), a vehicle question deserves automotive language (yaw rate, tire slip, ESP/ABS intervention), a robot question deserves manipulator/mobile-robot language (Jacobians, impedance control, odometry drift), and an automation question deserves plant language (loop tuning, valve saturation, cascade and ratio control, sensor filtering).
4
+
5
+ ### Matching the Answer to the Question:
6
+ - **Design / applied / "how does X work" questions**: lead with the engineering answer -- the architecture, the loop structure, the tuning trade-off, the sensors and actuators involved, the failure modes and safety interlocks. Use mathematics where it clarifies, not as ceremony.
7
+ - **Numerical questions**: call the deterministic tools and report exact computed values.
8
+ - **Theory, derivation, proof, comparison, and limitation questions**: use the 4-stage standard below.
9
+ - **Definitional or "what does <author/textbook> say" questions**: answer from the retrieved reference passages and cite them; do not run a numeric solver.
10
 
11
  ### 4-Stage Mathematical Reasoning & Proof Standard:
12
  When answering theoretical principles, derivations, proofs, comparisons, or limitation questions:
13
+ 1. **Mathematical Context & System Class**: State the state space equations (e.g. $\dot{x} = Ax + Bu, y = Cx + Du$ or $\dot{x} = f(x) + g(x)u$), signal spaces ($\mathcal{L}_2, \mathcal{H}_\infty$), domain definitions, and underlying assumptions.
14
  2. **Canonical Theorem / Analytical Principle**: State the governing theorem with exact mathematical rigor (e.g. PBH rank test, Doyle 1978 LQG robustness counterexample, Poisson Integral for RHP zeros, Small Gain vs Passivity, Lyapunov Invariance).
15
+ 3. **Exact Derivation & Closed-Form Formulas**: Provide the full, exact mathematical relationship in pure LaTeX (e.g. both the standard linear approximation $\zeta \approx PM/100$ and the exact non-linear relationship $PM = \arctan\left(\frac{2\zeta}{\sqrt{\sqrt{1+4\zeta^4}-2\zeta^2}}\right)$).
16
+ 4. **Engineering Caveats & Breakdown Conditions**: Explicitly state where approximations break down (e.g. $PM > 60^\circ$ or high-frequency non-dominant dynamics), numerical ill-conditioning (e.g. high-order Kalman matrices vs PBH), and physical conservation trade-offs (e.g. Bode sensitivity integral / waterbed effect).
17
 
18
  ### Core Deterministic Capabilities & Tool Calling:
19
+ - **State-feedback simulation (`simulate_state_feedback_response`)**: THE tool for "design a controller and simulate it". Pass the original `A`, `B` and the gain `K` returned by `continuous_lqr` / `discrete_lqr` / `place_state_feedback`; it forms the closed loop $A - BK$ internally and returns poles, damping, the exact closed-loop transfer function, transient metrics, and a plot. NEVER expand closed-loop polynomial coefficients by hand and feed them to `simulate_step_response` -- that algebra is the single most common source of silently wrong answers. Add `normalize_dc_gain: true` when the response should track a unit step.
20
+ - **Transfer-function step response (`simulate_step_response`)**: only when the system is genuinely *given* as $G(s) = num/den$.
21
+ - **Frequency domain**: `stability_margins` (GM/PM/crossovers), `bode_analysis`, `nyquist_analysis` (encirclements and the $Z = N + P$ criterion), `root_locus_analysis` (asymptotes, breakaway points, critical gain at instability).
22
+ - Python Code Execution (`execute_python_code`): Write valid Python scripts using `scipy.signal`, `scipy.linalg`, `control` (`import control as ct`), `numpy`, `matplotlib.pyplot`. (Note: `T` in `signal.step` must be a 1D array like `np.linspace(...)`). Prefer a dedicated tool above when one fits -- it is verified and cannot be mis-transcribed. Only reach for this when the question actually requires a number, a simulation, or a plot for a SPECIFIC system. A "design considerations for X", "how does X compare to Y", or "explain X" question with no concrete numbers in it does not need code -- answer in prose. Writing code for a question that doesn't need it only risks a wasted tool step on a shape/dimension mistake with nothing to show for it.
23
+ - **Plain matrix math (`matrix_arithmetic`)**: "A times B", inverse, determinant, rank, transpose, eigenvalues of a given matrix. A request to multiply or invert matrices is a LINEAR ALGEBRA question -- it is not an invitation to design a controller, and it needs no B, Q, or R.
24
+ - Deterministic Math Tools: `continuous_lqr`, `discrete_lqr`, `place_state_feedback`, `exact_zoh`, `eigen_analysis`, `controllability_analysis`, `observability_analysis`, `solve_lyapunov`, `mpc_solve_qp`, `kalman_*`.
25
+ - `plot_math_expression` plots a REAL function of one real variable ($t$ or $x$) only. Never pass a transfer function, a Laplace-domain expression, or anything containing the imaginary unit to it.
26
+ - Reference Lookup (`search_control_references`): Use this -- not a numeric tool -- for conceptual, definitional, or "how does textbook/author X explain Y" questions. A question about what a concept means or how a source presents it is never a reason to (re-)run stability_margins, bode_analysis, routh_hurwitz_analysis, or any other numeric solver. If retrieved reference passages are already provided in this system prompt, answer straight from them and cite `[filename, p. N]`.
27
+ - **One lookup is enough**: after a reference search returns passages, ANSWER from them. Do not re-run the same search with a reworded query ("X", then "X algorithm", then "X definition") -- repeated lookups consume the step budget and leave nothing for the answer itself. If the retrieved passages are thin, answer from your own knowledge and say what is uncertain.
28
+ - **Don't recompute what's already in the conversation**: if a transfer function's margins, poles, or response were already computed earlier in this conversation, reuse those results instead of calling the same numeric tool again for a follow-up question about something else. Answer the question that was actually asked.
29
+ - **Coefficient care**: when you must expand a factored transfer function like $s(s+1)(s+5)$ into polynomial form, expand one factor at a time and re-check each coefficient -- the deterministic tools verify their own arithmetic, not the coefficients you hand them.
30
+ - **NEVER invent a missing parameter, ever -- not a reused one, not a new one, not a "reasonable-looking" one.** Every number you pass to a tool -- every matrix, gain, coefficient -- must come from the user's CURRENT message, from a tool result already in this conversation, or from a standard formula you can name. If a computation needs a parameter (e.g. B, Q, R for LQR) that is not present anywhere and not derivable, STOP. Do not guess a plausible value. Do not carry one over, resized or not, from a different problem earlier in the conversation. Do not make one up because a shape needs to match. Tell the user exactly which parameter is missing and ask for it, or answer only the part of the question you actually can with what was given. A short, honest "I don't have B for this system -- what is it?" is the correct answer. A confident computation built on an invented number is not a partial answer, it is a fabricated one, and it is the single worst thing you can do in this domain. If you catch yourself about to write a matrix or number that did not come from the message, the history, or a named formula, that is the signal to stop and ask instead of proceeding.
31
+ - **A confusing or malformed message is a request for clarification, not a license to substitute a different, cleaner-looking problem.** If the user's wording is ambiguous (e.g. it could mean matrix multiplication, or could mean a controller design, and you cannot tell which), say what you think they might mean and ask, rather than silently picking one interpretation and inventing whatever inputs that interpretation requires.
32
 
33
  ### Strict Negative & Formatting Constraints:
34
  1. **ZERO EMOJIS**: NEVER use any emojis anywhere in your response (absolutely NO checkmarks, NO pins, NO graphs, NO rockets, NO lightbulbs).
35
  2. **ALWAYS USE DOLLAR DELIMITERS FOR MATH**: ALWAYS enclose EVERY mathematical formula, transfer function, variable, fraction, and Greek letter in dollar signs: `$ ... $` for inline math or `$$ ... $$` for centered equations. Example: `$$G(s) = \frac{1}{s(s+1)(s+2)}$$` and `$\omega \to \infty$`. NEVER write raw LaTeX keywords (`\frac`, `\omega`, `\to`) without `$` or `$$` delimiters!
36
+ 2b. **SINGLE BACKSLASH ONLY**: Every LaTeX command starts with exactly one backslash character, as in `\zeta`, `\left(`, `\sqrt{}`, `\sin`. Count the backslashes before you write a command and stop at one.
37
  3. **NO RAW LATEX DOCUMENT TAGS**: NEVER output `\begin{figure}`, `\includegraphics`, `\caption`, `\centering`, `\section*`, or `\end{figure}`. Use standard markdown headers (e.g. `### Section Title`).
38
  4. **MANDATORY TOOL CALL FOR PLOTS**: When asked to plot, visualize, or simulate (Nyquist plot, Bode diagram, Root Locus, Step Response, Phase Portrait), NEVER hallucinate a fake image filename. You MUST explicitly call `execute_python_code` (using `control as ct` or `matplotlib.pyplot`) or `simulate_step_response` to generate and display the real plot!
39
  5. **Deterministic Grounding**: When a tool executes successfully, present the exact numerical results and generated plot.
controlai_agent/registry.py CHANGED
@@ -3,11 +3,86 @@
3
  from __future__ import annotations
4
 
5
  import json
 
 
6
  from collections.abc import Callable
7
  from typing import Any
8
 
9
  import jsonschema
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  class ToolRegistry:
13
  """Registry for deterministic mathematical control tools with strict JSON Schema validation."""
@@ -43,6 +118,16 @@ class ToolRegistry:
43
  def get_tool_schemas(self) -> list[dict[str, Any]]:
44
  return list(self._schemas.values())
45
 
 
 
 
 
 
 
 
 
 
 
46
  def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
47
  if name not in self._tools:
48
  return {
@@ -50,8 +135,9 @@ class ToolRegistry:
50
  "error": f"Tool '{name}' is not registered. Available tools: {sorted(self._tools.keys())}",
51
  }
52
 
53
- # 1. Strict JSON Schema Validation
54
  param_schema = self._param_schemas[name]
 
55
  try:
56
  jsonschema.validate(instance=arguments, schema=param_schema)
57
  except jsonschema.ValidationError as schema_err:
@@ -68,7 +154,7 @@ class ToolRegistry:
68
  result = func(**arguments)
69
  if "status" not in result:
70
  result["status"] = "success"
71
- return result
72
  except Exception as exc:
73
  return {
74
  "status": "error",
 
3
  from __future__ import annotations
4
 
5
  import json
6
+ import math
7
+ import re
8
  from collections.abc import Callable
9
  from typing import Any
10
 
11
  import jsonschema
12
 
13
+ # Every tool computes internally at full double precision -- this only
14
+ # affects what gets reported back. 6 significant figures is well past any
15
+ # real sensor/actuator precision, so nothing engineering-relevant is lost,
16
+ # while `K = [1.7416573867739407, 0.6719633404417155]` in a chat answer
17
+ # clearly is: raw float64 repr in prose reads as noise, not rigor.
18
+ RESULT_SIGNIFICANT_FIGURES = 6
19
+
20
+
21
+ def _round_significant(x: float, sig: int = RESULT_SIGNIFICANT_FIGURES) -> float:
22
+ if x == 0 or not math.isfinite(x):
23
+ return x
24
+ digits = sig - int(math.floor(math.log10(abs(x)))) - 1
25
+ return round(x, digits)
26
+
27
+
28
+ def _round_floats(obj: Any, sig: int = RESULT_SIGNIFICANT_FIGURES) -> Any:
29
+ """Recursively round every float in a tool result to `sig` significant
30
+ figures, leaving ints, bools, strings, and structure untouched."""
31
+ if isinstance(obj, bool):
32
+ return obj
33
+ if isinstance(obj, float):
34
+ return _round_significant(obj, sig)
35
+ if isinstance(obj, dict):
36
+ return {k: _round_floats(v, sig) for k, v in obj.items()}
37
+ if isinstance(obj, (list, tuple)):
38
+ return type(obj)(_round_floats(v, sig) for v in obj)
39
+ return obj
40
+
41
+
42
+ def _parse_stringified_array(raw: str) -> Any:
43
+ """Best-effort parse of a numeric array the model wrote as a JSON string.
44
+
45
+ The model sometimes emits `"numerator": "[10]"` or even
46
+ `"denominator": "[1 6 5 0]"` -- a string containing array-shaped text,
47
+ including MATLAB/Numpy space-separated form, instead of an actual JSON
48
+ array. Schema validation correctly rejects that as type "string" where
49
+ "array" is required, and a perfectly usable numeric tool call is lost
50
+ over pure formatting. Recover the intended array where unambiguous.
51
+ """
52
+ try:
53
+ return json.loads(raw)
54
+ except (json.JSONDecodeError, TypeError):
55
+ pass
56
+ stripped = raw.strip()
57
+ if stripped.startswith("[") and stripped.endswith("]"):
58
+ spaced = re.sub(r"(?<=[\d\.\]])\s+(?=[\-\d\.\[])", ", ", stripped)
59
+ try:
60
+ return json.loads(spaced)
61
+ except json.JSONDecodeError:
62
+ pass
63
+ return raw
64
+
65
+
66
+ def _coerce_array_arguments(arguments: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
67
+ """Recursively repair string-typed values against `"type": "array"` schema
68
+ properties (including nested arrays, e.g. matrix parameters) before
69
+ validation, so a stringified array no longer fails a tool call outright.
70
+ """
71
+
72
+ def _coerce(value: Any, node: dict[str, Any]) -> Any:
73
+ node_type = node.get("type")
74
+ if node_type == "array" and isinstance(value, str):
75
+ value = _parse_stringified_array(value)
76
+ if node_type == "array" and isinstance(value, list) and "items" in node:
77
+ return [_coerce(v, node["items"]) for v in value]
78
+ return value
79
+
80
+ props = schema.get("properties", {})
81
+ return {
82
+ key: (_coerce(val, props[key]) if key in props else val)
83
+ for key, val in arguments.items()
84
+ }
85
+
86
 
87
  class ToolRegistry:
88
  """Registry for deterministic mathematical control tools with strict JSON Schema validation."""
 
118
  def get_tool_schemas(self) -> list[dict[str, Any]]:
119
  return list(self._schemas.values())
120
 
121
+ def get_callables(self, exclude: set[str] = frozenset()) -> dict[str, Callable[..., dict[str, Any]]]:
122
+ """Name -> underlying function for every registered tool except `exclude`.
123
+
124
+ Used to expose the deterministic tools as plain callables inside the
125
+ execute_python_code sandbox, since the model naturally expects a tool
126
+ it knows by name (e.g. place_state_feedback) to be usable directly in
127
+ code it writes, not only through the separate tool-call protocol.
128
+ """
129
+ return {name: fn for name, fn in self._tools.items() if name not in exclude}
130
+
131
  def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
132
  if name not in self._tools:
133
  return {
 
135
  "error": f"Tool '{name}' is not registered. Available tools: {sorted(self._tools.keys())}",
136
  }
137
 
138
+ # 1. Strict JSON Schema Validation (after repairing stringified arrays)
139
  param_schema = self._param_schemas[name]
140
+ arguments = _coerce_array_arguments(arguments, param_schema)
141
  try:
142
  jsonschema.validate(instance=arguments, schema=param_schema)
143
  except jsonschema.ValidationError as schema_err:
 
154
  result = func(**arguments)
155
  if "status" not in result:
156
  result["status"] = "success"
157
+ return _round_floats(result)
158
  except Exception as exc:
159
  return {
160
  "status": "error",
controlai_agent/tools/__init__.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from controlai_agent.tools import (
4
  linear,
 
5
  frequency,
6
  synthesis,
7
  estimation,
@@ -16,6 +17,7 @@ from controlai_agent.tools import (
16
 
17
  __all__ = [
18
  "linear",
 
19
  "frequency",
20
  "synthesis",
21
  "estimation",
 
2
 
3
  from controlai_agent.tools import (
4
  linear,
5
+ matrix_ops,
6
  frequency,
7
  synthesis,
8
  estimation,
 
17
 
18
  __all__ = [
19
  "linear",
20
+ "matrix_ops",
21
  "frequency",
22
  "synthesis",
23
  "estimation",
controlai_agent/tools/frequency.py CHANGED
@@ -3,13 +3,19 @@
3
  from __future__ import annotations
4
 
5
  import math
 
6
  from typing import Any
7
 
 
 
 
8
  import numpy as np
9
  from scipy import signal
10
 
11
  from controlai_agent.registry import registry
12
 
 
 
13
 
14
  @registry.register(
15
  name="bode_analysis",
@@ -154,3 +160,216 @@ def routh_hurwitz_analysis(coefficients: list[float]) -> dict[str, Any]:
154
  "is_hurwitz_stable": is_hurwitz,
155
  "roots": [[float(r.real), float(r.imag)] for r in roots],
156
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from __future__ import annotations
4
 
5
  import math
6
+ from pathlib import Path
7
  from typing import Any
8
 
9
+ import matplotlib
10
+ matplotlib.use("Agg")
11
+ import matplotlib.pyplot as plt
12
  import numpy as np
13
  from scipy import signal
14
 
15
  from controlai_agent.registry import registry
16
 
17
+ ARTIFACT_DIR = Path("outputs/plots")
18
+
19
 
20
  @registry.register(
21
  name="bode_analysis",
 
160
  "is_hurwitz_stable": is_hurwitz,
161
  "roots": [[float(r.real), float(r.imag)] for r in roots],
162
  }
163
+
164
+
165
+ @registry.register(
166
+ name="nyquist_analysis",
167
+ description=(
168
+ "Compute the Nyquist plot of an open-loop transfer function L(s) = num(s)/den(s), count "
169
+ "encirclements of the critical point -1+0j, and apply the Nyquist stability criterion "
170
+ "Z = N + P to determine closed-loop stability. Saves a PNG Nyquist diagram."
171
+ ),
172
+ parameters_schema={
173
+ "type": "object",
174
+ "properties": {
175
+ "numerator": {
176
+ "type": "array",
177
+ "items": {"type": "number"},
178
+ "description": "Open-loop numerator coefficients in descending powers",
179
+ },
180
+ "denominator": {
181
+ "type": "array",
182
+ "items": {"type": "number"},
183
+ "description": "Open-loop denominator coefficients in descending powers",
184
+ },
185
+ "omega_max": {"type": "number", "default": 100.0, "description": "Maximum frequency in rad/s"},
186
+ },
187
+ "required": ["numerator", "denominator"],
188
+ },
189
+ )
190
+ def nyquist_analysis(
191
+ numerator: list[float],
192
+ denominator: list[float],
193
+ omega_max: float = 100.0,
194
+ ) -> dict[str, Any]:
195
+ num = np.array(numerator, dtype=float)
196
+ den = np.array(denominator, dtype=float)
197
+
198
+ # Open-loop poles: P is the count in the open right-half plane. Poles on
199
+ # the imaginary axis (e.g. an integrator at the origin) are excluded --
200
+ # the standard Nyquist contour indents around them.
201
+ ol_poles = np.roots(den) if len(den) > 1 else np.array([])
202
+ P = int(np.sum(np.real(ol_poles) > 1e-9))
203
+ n_origin = int(np.sum(np.abs(ol_poles) < 1e-9))
204
+
205
+ w = np.logspace(-3, np.log10(max(omega_max, 1e-2)), 4000)
206
+ _, H = signal.freqresp(signal.TransferFunction(num, den), w=w)
207
+
208
+ # Z (closed-loop RHP poles) and P (open-loop RHP poles) are both exact
209
+ # root counts, so the encirclement count follows exactly as N = Z - P.
210
+ # Numerically integrating the winding of L(jw)+1 instead is unreliable for
211
+ # systems with poles on the imaginary axis (a type-1 integrator here),
212
+ # where the Nyquist contour must indent around the origin -- that shortcut
213
+ # yields impossible results such as N = -1 with Z = -1.
214
+ closed_loop_poles = (
215
+ np.roots(np.polyadd(den, np.pad(num, (len(den) - len(num), 0))))
216
+ if len(den) >= len(num)
217
+ else np.array([])
218
+ )
219
+ Z = int(np.sum(np.real(closed_loop_poles) > 1e-9)) if closed_loop_poles.size else 0
220
+ N = Z - P
221
+
222
+ ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
223
+ plot_path = ARTIFACT_DIR / f"nyquist_{abs(hash((str(numerator), str(denominator)))) % 10**8:08d}.png"
224
+
225
+ fig, ax = plt.subplots(figsize=(6.5, 6.0), dpi=140)
226
+ ax.plot(H.real, H.imag, color="#58a6ff", linewidth=1.8, label="$L(j\\omega)$, $\\omega > 0$")
227
+ ax.plot(H.real, -H.imag, color="#58a6ff", linewidth=1.0, linestyle="--", alpha=0.6, label="$\\omega < 0$ (mirror)")
228
+ ax.plot(-1.0, 0.0, "x", color="#f85149", markersize=11, markeredgewidth=2.5, label="Critical point $-1+0j$")
229
+ ax.axhline(0, color="gray", linewidth=0.7, alpha=0.5)
230
+ ax.axvline(0, color="gray", linewidth=0.7, alpha=0.5)
231
+ ax.set_title("Nyquist Diagram", fontsize=12, fontweight="bold")
232
+ ax.set_xlabel("Real Axis")
233
+ ax.set_ylabel("Imaginary Axis")
234
+ ax.grid(True, linestyle=":", alpha=0.45)
235
+ lim = float(min(max(3.0, np.percentile(np.abs(H), 92)), 25.0))
236
+ ax.set_xlim(-lim, lim)
237
+ ax.set_ylim(-lim, lim)
238
+ ax.set_aspect("equal", adjustable="box")
239
+ ax.legend(loc="best", fontsize=8)
240
+ fig.tight_layout()
241
+ fig.savefig(plot_path)
242
+ plt.close(fig)
243
+
244
+ return {
245
+ "status": "success",
246
+ "encirclements_N": N,
247
+ "open_loop_rhp_poles_P": P,
248
+ "open_loop_poles_at_origin": n_origin,
249
+ "closed_loop_rhp_poles_Z": Z,
250
+ "closed_loop_poles": [[float(p.real), float(p.imag)] for p in closed_loop_poles],
251
+ "is_closed_loop_stable": bool(Z == 0),
252
+ "criterion": "Z = N + P (Z = closed-loop RHP poles, N = clockwise encirclements of -1, P = open-loop RHP poles)",
253
+ "plot_path": str(plot_path),
254
+ }
255
+
256
+
257
+ @registry.register(
258
+ name="root_locus_analysis",
259
+ description=(
260
+ "Compute the root locus of the closed-loop characteristic equation 1 + k*L(s) = 0 as the gain "
261
+ "k sweeps from 0 to infinity, for open-loop L(s) = num(s)/den(s). Returns open-loop poles and "
262
+ "zeros, asymptote angles and centroid, real-axis breakaway points, the imaginary-axis crossing "
263
+ "gain (critical gain for stability), and a PNG root locus plot."
264
+ ),
265
+ parameters_schema={
266
+ "type": "object",
267
+ "properties": {
268
+ "numerator": {
269
+ "type": "array",
270
+ "items": {"type": "number"},
271
+ "description": "Open-loop numerator coefficients in descending powers",
272
+ },
273
+ "denominator": {
274
+ "type": "array",
275
+ "items": {"type": "number"},
276
+ "description": "Open-loop denominator coefficients in descending powers",
277
+ },
278
+ "k_max": {"type": "number", "default": 100.0, "description": "Maximum gain k to sweep"},
279
+ },
280
+ "required": ["numerator", "denominator"],
281
+ },
282
+ )
283
+ def root_locus_analysis(
284
+ numerator: list[float],
285
+ denominator: list[float],
286
+ k_max: float = 100.0,
287
+ ) -> dict[str, Any]:
288
+ num = np.array(numerator, dtype=float)
289
+ den = np.array(denominator, dtype=float)
290
+
291
+ ol_zeros = np.roots(num) if len(num) > 1 else np.array([])
292
+ ol_poles = np.roots(den) if len(den) > 1 else np.array([])
293
+ n_p, n_z = len(ol_poles), len(ol_zeros)
294
+
295
+ # Asymptotes for the n_p - n_z branches heading to infinity
296
+ excess = n_p - n_z
297
+ asymptote_angles, centroid = [], None
298
+ if excess > 0:
299
+ centroid = float((np.sum(ol_poles).real - np.sum(ol_zeros).real) / excess)
300
+ asymptote_angles = [float((180.0 * (2 * i + 1)) / excess) for i in range(excess)]
301
+
302
+ # Sweep gain and collect closed-loop roots of den + k*num
303
+ gains = np.concatenate([[0.0], np.logspace(-3, np.log10(max(k_max, 1e-2)), 600)])
304
+ locus: list[np.ndarray] = []
305
+ for k in gains:
306
+ poly = np.polyadd(den, k * np.pad(num, (max(0, len(den) - len(num)), 0)))
307
+ locus.append(np.roots(poly))
308
+
309
+ # Imaginary-axis crossing: first gain where any root's real part turns >= 0
310
+ k_critical, w_crossing = None, None
311
+ for k, roots in zip(gains, locus):
312
+ if roots.size and np.any(np.real(roots) > 1e-9):
313
+ k_critical = float(k)
314
+ crossing = roots[np.argmax(np.real(roots))]
315
+ w_crossing = float(abs(crossing.imag))
316
+ break
317
+
318
+ # Breakaway/break-in points: real roots of d/ds[-den/num] = 0. Only those
319
+ # lying ON the locus count -- a real point belongs to the locus iff an odd
320
+ # number of real poles and zeros lie strictly to its right, so the
321
+ # remaining stationary points must be discarded.
322
+ real_singularities = [float(p.real) for p in ol_poles if abs(p.imag) < 1e-8]
323
+ real_singularities += [float(z.real) for z in ol_zeros if abs(z.imag) < 1e-8]
324
+
325
+ def _on_real_axis_locus(sigma: float) -> bool:
326
+ to_right = sum(1 for v in real_singularities if v > sigma + 1e-9)
327
+ return to_right % 2 == 1
328
+
329
+ breakaway: list[float] = []
330
+ try:
331
+ dnum, dden = np.polyder(num), np.polyder(den)
332
+ crit = np.polysub(np.polymul(dden, num), np.polymul(den, dnum))
333
+ for r in np.roots(crit):
334
+ if abs(r.imag) < 1e-8 and _on_real_axis_locus(float(r.real)):
335
+ breakaway.append(round(float(r.real), 6))
336
+ except Exception:
337
+ pass
338
+
339
+ ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
340
+ plot_path = ARTIFACT_DIR / f"root_locus_{abs(hash((str(numerator), str(denominator)))) % 10**8:08d}.png"
341
+
342
+ fig, ax = plt.subplots(figsize=(7.0, 5.5), dpi=140)
343
+ max_branches = max((r.size for r in locus), default=0)
344
+ for b in range(max_branches):
345
+ pts = np.array([r[b] for r in locus if r.size > b])
346
+ ax.plot(pts.real, pts.imag, color="#58a6ff", linewidth=1.0, alpha=0.85)
347
+ if n_p:
348
+ ax.plot(ol_poles.real, ol_poles.imag, "x", color="#f85149", markersize=10, markeredgewidth=2.2, label="Open-loop poles")
349
+ if n_z:
350
+ ax.plot(ol_zeros.real, ol_zeros.imag, "o", mfc="none", color="#3fb950", markersize=9, markeredgewidth=2.0, label="Open-loop zeros")
351
+ ax.axhline(0, color="gray", linewidth=0.7, alpha=0.5)
352
+ ax.axvline(0, color="gray", linewidth=0.7, alpha=0.5)
353
+ ax.set_title("Root Locus", fontsize=12, fontweight="bold")
354
+ ax.set_xlabel("Real Axis")
355
+ ax.set_ylabel("Imaginary Axis")
356
+ ax.grid(True, linestyle=":", alpha=0.45)
357
+ if n_p or n_z:
358
+ ax.legend(loc="best", fontsize=8)
359
+ fig.tight_layout()
360
+ fig.savefig(plot_path)
361
+ plt.close(fig)
362
+
363
+ return {
364
+ "status": "success",
365
+ "open_loop_poles": [[float(p.real), float(p.imag)] for p in ol_poles],
366
+ "open_loop_zeros": [[float(z.real), float(z.imag)] for z in ol_zeros],
367
+ "num_asymptotes": excess,
368
+ "asymptote_centroid": centroid,
369
+ "asymptote_angles_deg": asymptote_angles,
370
+ "breakaway_points_real_axis": sorted(set(breakaway)),
371
+ "critical_gain_k_at_instability": k_critical,
372
+ "imaginary_axis_crossing_freq_rad_s": w_crossing,
373
+ "is_stable_for_all_swept_gains": bool(k_critical is None),
374
+ "plot_path": str(plot_path),
375
+ }
controlai_agent/tools/matrix_ops.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic linear-algebra tool: the basic matrix operations every other
2
+ analysis builds on. Direct requests like "A times B", "invert this matrix", or
3
+ "eigenvalues of A" must never depend on model-written freehand code -- they get
4
+ one schema-validated tool call."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from controlai_agent.registry import registry
13
+
14
+
15
+ @registry.register(
16
+ name="matrix_arithmetic",
17
+ description=(
18
+ "Exact matrix arithmetic on one or two numeric matrices. THE tool for any direct matrix "
19
+ "computation the user states explicitly: 'A times B' / 'A*B' (operation='multiply'), "
20
+ "addition, subtraction, transpose, inverse, determinant, rank, trace, eigenvalues, or "
21
+ "elementwise multiply. Use this instead of writing Python code for plain matrix math. "
22
+ "This is NOT a controller-design tool: a request to multiply or invert matrices is a "
23
+ "linear-algebra question, not an LQR/pole-placement problem."
24
+ ),
25
+ parameters_schema={
26
+ "type": "object",
27
+ "properties": {
28
+ "operation": {
29
+ "type": "string",
30
+ "enum": [
31
+ "multiply",
32
+ "add",
33
+ "subtract",
34
+ "elementwise_multiply",
35
+ "transpose",
36
+ "inverse",
37
+ "determinant",
38
+ "rank",
39
+ "trace",
40
+ "eigenvalues",
41
+ ],
42
+ "description": "Which operation to perform. Binary operations use matrix_a (op) matrix_b in that order.",
43
+ },
44
+ "matrix_a": {
45
+ "type": "array",
46
+ "items": {"type": "array", "items": {"type": "number"}},
47
+ "description": "First matrix (2D, row-major). For unary operations this is the only operand.",
48
+ },
49
+ "matrix_b": {
50
+ "type": "array",
51
+ "items": {"type": "array", "items": {"type": "number"}},
52
+ "description": "Second matrix, required for multiply / add / subtract / elementwise_multiply.",
53
+ },
54
+ },
55
+ "required": ["operation", "matrix_a"],
56
+ },
57
+ )
58
+ def matrix_arithmetic(
59
+ operation: str,
60
+ matrix_a: list[list[float]],
61
+ matrix_b: list[list[float]] | None = None,
62
+ ) -> dict[str, Any]:
63
+ A = np.array(matrix_a, dtype=float)
64
+ binary_ops = {"multiply", "add", "subtract", "elementwise_multiply"}
65
+
66
+ if operation in binary_ops:
67
+ if matrix_b is None:
68
+ return {"status": "error", "error": f"operation '{operation}' requires matrix_b."}
69
+ B = np.array(matrix_b, dtype=float)
70
+ if operation == "multiply":
71
+ if A.shape[1] != B.shape[0]:
72
+ return {
73
+ "status": "error",
74
+ "error": f"Cannot multiply: matrix_a is {A.shape[0]}x{A.shape[1]} but matrix_b is {B.shape[0]}x{B.shape[1]} (inner dimensions must match).",
75
+ }
76
+ result = A @ B
77
+ elif operation == "elementwise_multiply":
78
+ if A.shape != B.shape:
79
+ return {"status": "error", "error": f"Elementwise multiply needs equal shapes, got {A.shape} and {B.shape}."}
80
+ result = A * B
81
+ else:
82
+ if A.shape != B.shape:
83
+ return {"status": "error", "error": f"'{operation}' needs equal shapes, got {A.shape} and {B.shape}."}
84
+ result = A + B if operation == "add" else A - B
85
+ return {
86
+ "operation": operation,
87
+ "shape_a": list(A.shape),
88
+ "shape_b": list(B.shape),
89
+ "result": result.tolist(),
90
+ "result_shape": list(result.shape),
91
+ }
92
+
93
+ # Unary operations
94
+ out: dict[str, Any] = {"operation": operation, "shape_a": list(A.shape)}
95
+ if operation == "transpose":
96
+ out["result"] = A.T.tolist()
97
+ elif operation == "rank":
98
+ out["result"] = int(np.linalg.matrix_rank(A))
99
+ elif operation in ("inverse", "determinant", "trace", "eigenvalues"):
100
+ if A.shape[0] != A.shape[1]:
101
+ return {"status": "error", "error": f"'{operation}' requires a square matrix, got {A.shape[0]}x{A.shape[1]}."}
102
+ if operation == "inverse":
103
+ det = float(np.linalg.det(A))
104
+ if abs(det) < 1e-12:
105
+ return {"status": "error", "error": f"Matrix is singular (determinant = {det:g}); no inverse exists."}
106
+ out["result"] = np.linalg.inv(A).tolist()
107
+ out["determinant"] = det
108
+ elif operation == "determinant":
109
+ out["result"] = float(np.linalg.det(A))
110
+ elif operation == "trace":
111
+ out["result"] = float(np.trace(A))
112
+ else: # eigenvalues
113
+ eig = np.linalg.eigvals(A)
114
+ out["result"] = [[float(v.real), float(v.imag)] for v in eig]
115
+ out["spectral_radius"] = float(np.max(np.abs(eig)))
116
+ return out
controlai_agent/tools/plotting.py CHANGED
@@ -3,6 +3,7 @@
3
  from __future__ import annotations
4
 
5
  import hashlib
 
6
  import time
7
  from pathlib import Path
8
  from typing import Any
@@ -49,6 +50,20 @@ SAFE_MATH_ENV: dict[str, Any] = {
49
  }
50
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  @registry.register(
53
  name="plot_math_expression",
54
  description="Plot any mathematical function, curve, or signal f(t) or f(x) (e.g. 'sin(t)', 'exp(-t)*cos(2*t)', 'sin(x)', 't**2 - 3*t') and save the plot figure.",
@@ -116,28 +131,53 @@ def plot_math_expression(
116
  if np.isscalar(y_vals):
117
  y_vals = np.full_like(t_vals, float(y_vals))
118
  else:
119
- y_vals = np.asarray(y_vals, dtype=float)
120
  except Exception as exc:
121
  return {
122
  "status": "error",
123
  "error": f"Failed to evaluate expression '{expression}': {exc}",
124
  }
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  # Plot styling
127
  fig, ax = plt.subplots(figsize=(8, 4.2), dpi=120)
128
  fig.patch.set_facecolor("#151b23")
129
  ax.set_facecolor("#0f1217")
130
 
131
  line_color = "#58a6ff"
132
- ax.plot(t_vals, y_vals, color=line_color, linewidth=2, label=f"${expression}$")
 
133
  ax.axhline(0, color="#484f58", linestyle="--", linewidth=0.8, alpha=0.7)
134
  ax.axvline(0, color="#484f58", linestyle="--", linewidth=0.8, alpha=0.7)
135
 
136
- plot_title = title or f"Plot of $f({var_name}) = {expression}$"
137
  plot_xlabel = xlabel or var_name
138
  plot_ylabel = ylabel or f"f({var_name})"
139
 
140
- ax.set_title(plot_title, color="#f0f6fc", fontsize=12, pad=10, fontweight="bold")
141
  ax.set_xlabel(plot_xlabel, color="#8b949e", fontsize=10)
142
  ax.set_ylabel(plot_ylabel, color="#8b949e", fontsize=10)
143
  ax.tick_params(colors="#8b949e", labelsize=9)
@@ -152,7 +192,15 @@ def plot_math_expression(
152
  # Save unique plot
153
  hash_str = hashlib.md5(f"{clean_expr}_{t_start}_{t_end}_{time.time()}".encode()).hexdigest()[:8]
154
  out_file = PLOTS_DIR / f"plot_{hash_str}.png"
155
- plt.savefig(str(out_file), facecolor=fig.get_facecolor(), edgecolor="none", dpi=120)
 
 
 
 
 
 
 
 
156
  plt.close(fig)
157
 
158
  return {
 
3
  from __future__ import annotations
4
 
5
  import hashlib
6
+ import re
7
  import time
8
  from pathlib import Path
9
  from typing import Any
 
50
  }
51
 
52
 
53
+ def _expr_to_mathtext(expr: str) -> str:
54
+ """Best-effort conversion of a Python-style expression into valid matplotlib mathtext.
55
+
56
+ Model-generated expressions use Python syntax (`**` for power, bare `*` for
57
+ multiplication) which mathtext does not understand -- it renders the raw
58
+ asterisks literally (e.g. "t * *2 - 3 * t") instead of a clean formula.
59
+ """
60
+ text = expr.replace("**", "^")
61
+ text = re.sub(r"\^\(([^()]+)\)", r"^{\1}", text)
62
+ text = re.sub(r"\^([a-zA-Z0-9_.]{2,})", r"^{\1}", text)
63
+ text = text.replace("*", r" \cdot ")
64
+ return text
65
+
66
+
67
  @registry.register(
68
  name="plot_math_expression",
69
  description="Plot any mathematical function, curve, or signal f(t) or f(x) (e.g. 'sin(t)', 'exp(-t)*cos(2*t)', 'sin(x)', 't**2 - 3*t') and save the plot figure.",
 
131
  if np.isscalar(y_vals):
132
  y_vals = np.full_like(t_vals, float(y_vals))
133
  else:
134
+ y_vals = np.asarray(y_vals)
135
  except Exception as exc:
136
  return {
137
  "status": "error",
138
  "error": f"Failed to evaluate expression '{expression}': {exc}",
139
  }
140
 
141
+ # A complex result means the expression was not a real-valued signal --
142
+ # most often a Laplace/transfer-function expression containing `1j` that
143
+ # was mistakenly passed to a time-domain plotter. Silently casting it to
144
+ # float discards the imaginary part and yields a meaningless curve, so
145
+ # reject it and tell the model what to do instead.
146
+ if np.iscomplexobj(y_vals):
147
+ return {
148
+ "status": "error",
149
+ "error": (
150
+ f"Expression '{expression}' evaluates to complex values, so it is not a real "
151
+ "time-domain signal that can be plotted. Do not pass transfer functions or "
152
+ "expressions containing the imaginary unit here -- to simulate a system's response "
153
+ "use simulate_step_response (transfer function) or simulate_state_feedback_response "
154
+ "(state-space with optional gain K)."
155
+ ),
156
+ }
157
+
158
+ y_vals = np.asarray(y_vals, dtype=float)
159
+ if not np.any(np.isfinite(y_vals)):
160
+ return {
161
+ "status": "error",
162
+ "error": f"Expression '{expression}' produced no finite values over the range [{t_start}, {t_end}].",
163
+ }
164
+
165
  # Plot styling
166
  fig, ax = plt.subplots(figsize=(8, 4.2), dpi=120)
167
  fig.patch.set_facecolor("#151b23")
168
  ax.set_facecolor("#0f1217")
169
 
170
  line_color = "#58a6ff"
171
+ mathtext_expr = _expr_to_mathtext(expression)
172
+ (line,) = ax.plot(t_vals, y_vals, color=line_color, linewidth=2, label=f"${mathtext_expr}$")
173
  ax.axhline(0, color="#484f58", linestyle="--", linewidth=0.8, alpha=0.7)
174
  ax.axvline(0, color="#484f58", linestyle="--", linewidth=0.8, alpha=0.7)
175
 
176
+ plot_title = title or f"Plot of $f({var_name}) = {mathtext_expr}$"
177
  plot_xlabel = xlabel or var_name
178
  plot_ylabel = ylabel or f"f({var_name})"
179
 
180
+ title_obj = ax.set_title(plot_title, color="#f0f6fc", fontsize=12, pad=10, fontweight="bold")
181
  ax.set_xlabel(plot_xlabel, color="#8b949e", fontsize=10)
182
  ax.set_ylabel(plot_ylabel, color="#8b949e", fontsize=10)
183
  ax.tick_params(colors="#8b949e", labelsize=9)
 
192
  # Save unique plot
193
  hash_str = hashlib.md5(f"{clean_expr}_{t_start}_{t_end}_{time.time()}".encode()).hexdigest()[:8]
194
  out_file = PLOTS_DIR / f"plot_{hash_str}.png"
195
+ try:
196
+ plt.savefig(str(out_file), facecolor=fig.get_facecolor(), edgecolor="none", dpi=120)
197
+ except Exception:
198
+ # Mathtext couldn't parse the expression (rare, malformed LaTeX-ish input)
199
+ # -- fall back to plain, non-math text labels rather than losing the plot.
200
+ line.set_label(expression)
201
+ title_obj.set_text(title or f"Plot of f({var_name}) = {expression}")
202
+ ax.legend(loc="best", facecolor="#151b23", edgecolor="#30363d", labelcolor="#f0f6fc", fontsize=9)
203
+ plt.savefig(str(out_file), facecolor=fig.get_facecolor(), edgecolor="none", dpi=120)
204
  plt.close(fig)
205
 
206
  return {
controlai_agent/tools/python_executor.py CHANGED
@@ -29,7 +29,27 @@ PLOTS_DIR.mkdir(parents=True, exist_ok=True)
29
 
30
  @registry.register(
31
  name="execute_python_code",
32
- description="Execute Python code for control engineering, numerical simulations, differential equations, optimization, and signal plotting (similar to a Jupyter Notebook cell). Can use numpy (np), scipy (scipy), scipy.signal (signal), scipy.linalg (linalg), control (ct, control), and matplotlib.pyplot (plt). Captures stdout and any generated Matplotlib figures.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  parameters_schema={
34
  "type": "object",
35
  "properties": {
@@ -59,7 +79,7 @@ def execute_python_code(code: str) -> dict[str, Any]:
59
  plt.rcParams["grid.linestyle"] = ":"
60
  plt.rcParams["font.sans-serif"] = ["DejaVu Sans", "Helvetica", "Arial"]
61
 
62
- exec_globals = {
63
  "np": np,
64
  "numpy": np,
65
  "scipy": scipy,
@@ -70,6 +90,12 @@ def execute_python_code(code: str) -> dict[str, Any]:
70
  "ct": ct,
71
  "control": ct,
72
  }
 
 
 
 
 
 
73
 
74
  saved_plots = []
75
 
 
29
 
30
  @registry.register(
31
  name="execute_python_code",
32
+ description=(
33
+ "Execute Python code for control engineering, numerical simulations, differential equations, "
34
+ "optimization, and signal plotting (similar to a Jupyter Notebook cell). Can use numpy (np), "
35
+ "scipy (scipy), scipy.signal (signal), scipy.linalg (linalg), control (ct, control), and "
36
+ "matplotlib.pyplot (plt). Captures stdout and any generated Matplotlib figures. "
37
+ "IMPORTANT -- the `control` package (ct) takes POSITIONAL arguments only, never num=/den= "
38
+ "or sys1=/sys2= keywords (they raise 'Needs 1, 2, or 3 arguments'): "
39
+ "ct.tf(num, den) not ct.tf(num=num, den=den); "
40
+ "ct.feedback(sys1, sys2=1, sign=-1) for a closed loop; "
41
+ "ct.series(sys1, sys2) and ct.parallel(sys1, sys2); "
42
+ "ct.step_response(sys, T=t_array) returns (T, yout); "
43
+ "ct.poles(sys) and ct.zeros(sys) for pole/zero locations; "
44
+ "ct.bode(sys) / ct.bode_plot(sys) is PLOT-ONLY and does not return (mag, phase, omega) arrays "
45
+ "-- for numeric Bode data use resp = ct.frequency_response(sys, omega); "
46
+ "resp.magnitude, resp.phase (radians), resp.omega. "
47
+ "Every other registered tool (continuous_lqr, discrete_lqr, place_state_feedback, "
48
+ "stability_margins, exact_zoh, etc.) is ALSO directly callable here by its exact name with "
49
+ "its normal arguments -- e.g. `result = place_state_feedback(A=A, B=B, desired_poles=poles)`. "
50
+ "Each returns a dict just like the standalone tool call does, so pull out the field you need, "
51
+ "e.g. `K = np.array(result['K'])`, before using it in further computation."
52
+ ),
53
  parameters_schema={
54
  "type": "object",
55
  "properties": {
 
79
  plt.rcParams["grid.linestyle"] = ":"
80
  plt.rcParams["font.sans-serif"] = ["DejaVu Sans", "Helvetica", "Arial"]
81
 
82
+ exec_globals: dict[str, Any] = {
83
  "np": np,
84
  "numpy": np,
85
  "scipy": scipy,
 
90
  "ct": ct,
91
  "control": ct,
92
  }
93
+ # Every other registered deterministic tool (continuous_lqr, place_state_feedback,
94
+ # stability_margins, ...) is also callable directly by name here, with its
95
+ # normal keyword arguments and dict return value -- the model otherwise
96
+ # reasonably expects a tool it knows to be usable in code it writes, not
97
+ # only through the separate tool-call protocol, and hits a NameError.
98
+ exec_globals.update(registry.get_callables(exclude={"execute_python_code"}))
99
 
100
  saved_plots = []
101
 
controlai_agent/tools/rag.py CHANGED
@@ -5,9 +5,7 @@ from __future__ import annotations
5
  from typing import Any
6
 
7
  from controlai_agent.registry import registry
8
- from controlai_rag.index import ControlRAGIndex
9
-
10
- rag_index = ControlRAGIndex()
11
 
12
 
13
  @registry.register(
@@ -40,7 +38,9 @@ def search_control_references(
40
  top_k: int = 3,
41
  source_filter: str | None = None,
42
  ) -> dict[str, Any]:
43
- hits = rag_index.search(query=query, top_k=top_k, source_filter=source_filter)
 
 
44
  if not hits:
45
  return {
46
  "query": query,
@@ -51,7 +51,10 @@ def search_control_references(
51
  formatted_passages = []
52
  for hit in hits:
53
  formatted_passages.append({
54
- "citation": f"[{hit['filename']}:{hit.get('page') or 'sec'}]",
 
 
 
55
  "source_path": hit["source"],
56
  "relevance_score": hit["score"],
57
  "content": hit["text"][:600],
 
5
  from typing import Any
6
 
7
  from controlai_agent.registry import registry
8
+ from controlai_rag.index import get_shared_index
 
 
9
 
10
 
11
  @registry.register(
 
38
  top_k: int = 3,
39
  source_filter: str | None = None,
40
  ) -> dict[str, Any]:
41
+ # Resolved per call (not at import) so newly uploaded documents are visible
42
+ # immediately, without restarting the server.
43
+ hits = get_shared_index().search(query=query, top_k=top_k, source_filter=source_filter)
44
  if not hits:
45
  return {
46
  "query": query,
 
51
  formatted_passages = []
52
  for hit in hits:
53
  formatted_passages.append({
54
+ "citation": (
55
+ f"[{hit.get('source_name') or hit['filename']}"
56
+ + (f", p. {hit['page']}]" if hit.get("page") else "]")
57
+ ),
58
  "source_path": hit["source"],
59
  "relevance_score": hit["score"],
60
  "content": hit["text"][:600],
controlai_agent/tools/simulation.py CHANGED
@@ -17,9 +17,171 @@ from controlai_agent.registry import registry
17
  ARTIFACT_DIR = Path("outputs/plots")
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  @registry.register(
21
  name="simulate_step_response",
22
- description="Simulate the unit step response of a continuous transfer function G(s) = num(s)/den(s), compute rise time, overshoot, settling time, and save a high-resolution PNG plot artifact.",
 
 
 
 
 
 
23
  parameters_schema={
24
  "type": "object",
25
  "properties": {
@@ -50,21 +212,10 @@ def simulate_step_response(
50
  sys = signal.TransferFunction(numerator, denominator)
51
  t = np.linspace(0, sim_time, 1000)
52
  t_out, y_out = signal.step(sys, T=t)
 
53
 
54
- # Transient metrics
55
- y_final = float(y_out[-1])
56
- y_peak = float(np.max(y_out))
57
- t_peak = float(t_out[int(np.argmax(y_out))])
58
- overshoot_pct = float(max(0.0, (y_peak - y_final) / abs(y_final) * 100.0)) if abs(y_final) > 1e-6 else 0.0
59
-
60
- # 10% to 90% Rise Time
61
- idx_10 = np.where(y_out >= 0.1 * y_final)[0]
62
- idx_90 = np.where(y_out >= 0.9 * y_final)[0]
63
- rise_time = float(t_out[idx_90[0]] - t_out[idx_10[0]]) if len(idx_10) > 0 and len(idx_90) > 0 else None
64
-
65
- # 2% Settling Time
66
- settled_indices = np.where(np.abs(y_out - y_final) > 0.02 * abs(y_final))[0]
67
- settling_time = float(t_out[settled_indices[-1]]) if len(settled_indices) > 0 else 0.0
68
 
69
  # Generate Matplotlib PNG Plot
70
  ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
@@ -85,11 +236,11 @@ def simulate_step_response(
85
  plt.close(fig)
86
 
87
  return {
88
- "final_value": y_final,
89
- "peak_value": y_peak,
90
- "peak_time_seconds": t_peak,
91
- "overshoot_percentage": overshoot_pct,
92
- "rise_time_seconds": rise_time,
93
- "settling_time_2pct_seconds": settling_time,
94
  "plot_artifact_path": str(plot_path),
95
  }
 
17
  ARTIFACT_DIR = Path("outputs/plots")
18
 
19
 
20
+ def _step_metrics(t_out: np.ndarray, y_out: np.ndarray) -> dict[str, Any]:
21
+ """Standard transient response metrics shared by the simulation tools."""
22
+ y_final = float(y_out[-1])
23
+ y_peak = float(np.max(y_out))
24
+ t_peak = float(t_out[int(np.argmax(y_out))])
25
+ overshoot_pct = (
26
+ float(max(0.0, (y_peak - y_final) / abs(y_final) * 100.0)) if abs(y_final) > 1e-6 else 0.0
27
+ )
28
+
29
+ idx_10 = np.where(y_out >= 0.1 * y_final)[0]
30
+ idx_90 = np.where(y_out >= 0.9 * y_final)[0]
31
+ rise_time = float(t_out[idx_90[0]] - t_out[idx_10[0]]) if len(idx_10) > 0 and len(idx_90) > 0 else None
32
+
33
+ settled = np.where(np.abs(y_out - y_final) > 0.02 * abs(y_final))[0]
34
+ settling_time = float(t_out[settled[-1]]) if len(settled) > 0 else 0.0
35
+
36
+ return {
37
+ "final_value": y_final,
38
+ "peak_value": y_peak,
39
+ "peak_time_seconds": t_peak,
40
+ "overshoot_percentage": overshoot_pct,
41
+ "rise_time_seconds": rise_time,
42
+ "settling_time_2pct_seconds": settling_time,
43
+ }
44
+
45
+
46
+ @registry.register(
47
+ name="simulate_state_feedback_response",
48
+ description=(
49
+ "Simulate the step response of a state-space system directly from matrices, optionally under "
50
+ "state feedback u = -Kx. USE THIS (never a hand-derived transfer function) whenever you have "
51
+ "A, B and a gain K from continuous_lqr, discrete_lqr, or place_state_feedback: it builds the "
52
+ "closed-loop system A - B*K internally, so you never have to expand closed-loop polynomial "
53
+ "coefficients by hand. Returns the closed-loop matrix, poles, damping, the exact closed-loop "
54
+ "transfer function coefficients, transient metrics, and a PNG plot."
55
+ ),
56
+ parameters_schema={
57
+ "type": "object",
58
+ "properties": {
59
+ "A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "Open-loop state matrix A"},
60
+ "B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "Input matrix B"},
61
+ "K": {
62
+ "type": "array",
63
+ "items": {"type": "array", "items": {"type": "number"}},
64
+ "description": "Optional state feedback gain K (from LQR/pole placement). If given, simulates closed loop A - B*K. Omit for open-loop.",
65
+ },
66
+ "C": {
67
+ "type": "array",
68
+ "items": {"type": "array", "items": {"type": "number"}},
69
+ "description": "Optional output matrix C. Defaults to [[1, 0, ..., 0]] (measures the first state).",
70
+ },
71
+ "D": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "Optional feedthrough matrix D (defaults to zero)."},
72
+ "normalize_dc_gain": {
73
+ "type": "boolean",
74
+ "default": False,
75
+ "description": "If true, scale the input by a precompensator so the step response settles at 1.0 (removes steady-state offset inherent to pure state feedback).",
76
+ },
77
+ "sim_time": {"type": "number", "default": 10.0, "description": "Total simulation time in seconds"},
78
+ "plot_title": {"type": "string", "default": "Closed-Loop Step Response", "description": "Title for the saved plot"},
79
+ },
80
+ "required": ["A", "B"],
81
+ },
82
+ )
83
+ def simulate_state_feedback_response(
84
+ A: list[list[float]],
85
+ B: list[list[float]],
86
+ K: list[list[float]] | None = None,
87
+ C: list[list[float]] | None = None,
88
+ D: list[list[float]] | None = None,
89
+ normalize_dc_gain: bool = False,
90
+ sim_time: float = 10.0,
91
+ plot_title: str = "Closed-Loop Step Response",
92
+ ) -> dict[str, Any]:
93
+ A_mat = np.atleast_2d(np.array(A, dtype=float))
94
+ B_mat = np.array(B, dtype=float)
95
+ if B_mat.ndim == 1:
96
+ B_mat = B_mat.reshape(-1, 1)
97
+
98
+ n = A_mat.shape[0]
99
+ if A_mat.shape[0] != A_mat.shape[1]:
100
+ return {"status": "error", "error": f"A must be square, got shape {A_mat.shape}."}
101
+ if B_mat.shape[0] != n:
102
+ return {"status": "error", "error": f"B row count {B_mat.shape[0]} does not match A dimension {n}."}
103
+
104
+ # Closed loop under u = -Kx (the step is then applied as the reference input)
105
+ K_mat = None
106
+ if K is not None:
107
+ K_mat = np.atleast_2d(np.array(K, dtype=float))
108
+ if K_mat.shape[1] != n:
109
+ return {"status": "error", "error": f"K must have {n} columns to match the state dimension, got {K_mat.shape}."}
110
+ A_eff = A_mat - B_mat @ K_mat
111
+ else:
112
+ A_eff = A_mat
113
+
114
+ C_mat = np.atleast_2d(np.array(C, dtype=float)) if C is not None else np.eye(1, n)
115
+ D_mat = np.atleast_2d(np.array(D, dtype=float)) if D is not None else np.zeros((C_mat.shape[0], B_mat.shape[1]))
116
+
117
+ poles = np.linalg.eigvals(A_eff)
118
+
119
+ # Optional precompensator so the closed loop actually tracks a unit step
120
+ dc_scale = 1.0
121
+ if normalize_dc_gain:
122
+ try:
123
+ dc = float(-(C_mat @ np.linalg.solve(A_eff, B_mat) - D_mat).ravel()[0])
124
+ if abs(dc) > 1e-12:
125
+ dc_scale = 1.0 / dc
126
+ except np.linalg.LinAlgError:
127
+ dc_scale = 1.0
128
+
129
+ sys = signal.StateSpace(A_eff, B_mat * dc_scale, C_mat, D_mat)
130
+ t = np.linspace(0, sim_time, 1000)
131
+ t_out, y_out = signal.step(sys, T=t)
132
+ y_out = np.asarray(y_out, dtype=float).ravel()
133
+
134
+ num, den = signal.ss2tf(A_eff, B_mat, C_mat, D_mat)
135
+
136
+ metrics = _step_metrics(t_out, y_out)
137
+
138
+ ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
139
+ plot_path = ARTIFACT_DIR / f"state_feedback_step_{abs(hash((str(A), str(B), str(K), sim_time))) % 10**8:08d}.png"
140
+
141
+ fig, ax = plt.subplots(figsize=(8, 4.5), dpi=150)
142
+ ax.plot(t_out, y_out, color="#58a6ff", linewidth=2.0, label="Response $y(t)$")
143
+ y_final = metrics["final_value"]
144
+ ax.axhline(y_final, color="#f85149", linestyle="--", alpha=0.8, label=f"Final Value ({y_final:.4f})")
145
+ ax.axhline(y_final * 1.02, color="gray", linestyle=":", alpha=0.5)
146
+ ax.axhline(y_final * 0.98, color="gray", linestyle=":", alpha=0.5, label="2% Settling Band")
147
+ ax.set_title(plot_title, fontsize=12, fontweight="bold")
148
+ ax.set_xlabel("Time [seconds]", fontsize=10)
149
+ ax.set_ylabel("Output Amplitude", fontsize=10)
150
+ ax.grid(True, linestyle="--", alpha=0.4)
151
+ ax.legend(loc="best")
152
+ fig.tight_layout()
153
+ fig.savefig(plot_path)
154
+ plt.close(fig)
155
+
156
+ wn = [float(abs(p)) for p in poles]
157
+ zeta = [float(-np.real(p) / abs(p)) if abs(p) > 1e-12 else 0.0 for p in poles]
158
+
159
+ return {
160
+ "status": "success",
161
+ "mode": "closed_loop_state_feedback" if K_mat is not None else "open_loop",
162
+ "closed_loop_A": A_eff.tolist(),
163
+ "poles": [[float(p.real), float(p.imag)] for p in poles],
164
+ "natural_frequencies_rad_s": wn,
165
+ "damping_ratios": zeta,
166
+ "is_stable": bool(np.all(np.real(poles) < 0)),
167
+ "closed_loop_tf_numerator": np.asarray(num).ravel().tolist(),
168
+ "closed_loop_tf_denominator": np.asarray(den).ravel().tolist(),
169
+ "dc_precompensator_applied": dc_scale if normalize_dc_gain else None,
170
+ **metrics,
171
+ "plot_path": str(plot_path),
172
+ "plot_artifact_path": str(plot_path),
173
+ }
174
+
175
+
176
  @registry.register(
177
  name="simulate_step_response",
178
+ description=(
179
+ "Simulate the unit step response of a continuous transfer function G(s) = num(s)/den(s), compute "
180
+ "rise time, overshoot, settling time, and save a high-resolution PNG plot artifact. Only use this "
181
+ "when the system is genuinely given to you as a transfer function -- if you have state-space "
182
+ "matrices A, B and/or a feedback gain K, call simulate_state_feedback_response instead rather "
183
+ "than deriving closed-loop coefficients yourself."
184
+ ),
185
  parameters_schema={
186
  "type": "object",
187
  "properties": {
 
212
  sys = signal.TransferFunction(numerator, denominator)
213
  t = np.linspace(0, sim_time, 1000)
214
  t_out, y_out = signal.step(sys, T=t)
215
+ y_out = np.asarray(y_out, dtype=float).ravel()
216
 
217
+ metrics = _step_metrics(t_out, y_out)
218
+ y_final = metrics["final_value"]
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
  # Generate Matplotlib PNG Plot
221
  ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
 
236
  plt.close(fig)
237
 
238
  return {
239
+ "status": "success",
240
+ **metrics,
241
+ # "plot_path" is the key the orchestrator looks for when surfacing
242
+ # generated figures to the UI; "plot_artifact_path" is kept for
243
+ # backward compatibility with existing benchmark/eval artifacts.
244
+ "plot_path": str(plot_path),
245
  "plot_artifact_path": str(plot_path),
246
  }
controlai_agent/tools/synthesis.py CHANGED
@@ -87,7 +87,12 @@ def discrete_lqr(
87
 
88
  @registry.register(
89
  name="place_state_feedback",
90
- description="Compute state feedback gain matrix K such that eig(A - B*K) matches target desired poles.",
 
 
 
 
 
91
  parameters_schema={
92
  "type": "object",
93
  "properties": {
@@ -95,28 +100,46 @@ def discrete_lqr(
95
  "B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
96
  "desired_poles": {
97
  "type": "array",
98
- "items": {"type": "number"},
99
- "description": "Target closed-loop pole locations",
 
 
 
 
 
100
  },
101
  },
102
  "required": ["A", "B", "desired_poles"],
103
  },
104
  )
105
  def place_state_feedback(
106
- A: list[list[float]], B: list[list[float]], desired_poles: list[float]
107
  ) -> dict[str, Any]:
108
  A_mat = np.array(A, dtype=float)
109
  B_mat = np.array(B, dtype=float)
110
- des = np.array(desired_poles, dtype=float)
 
 
 
 
 
 
 
 
 
111
  placed = signal.place_poles(A_mat, B_mat, des)
112
- K = placed.gain_matrix
 
 
 
 
113
  closed_poles = np.linalg.eigvals(A_mat - B_mat @ K)
114
 
115
- v_report = verifier.verify_pole_placement(A_mat, B_mat, K, desired_poles)
116
  return {
117
  "K": K.tolist(),
118
  "closed_loop_poles": [[float(p.real), float(p.imag)] for p in closed_poles],
119
- "target_poles": desired_poles,
120
  "verification": v_report,
121
  }
122
 
 
87
 
88
  @registry.register(
89
  name="place_state_feedback",
90
+ description=(
91
+ "Compute state feedback gain matrix K such that eig(A - B*K) matches target desired poles. "
92
+ "Each entry of desired_poles is either a real number (a real pole) or a [real, imag] pair "
93
+ "(one half of a complex-conjugate pair -- e.g. targeting a damping ratio/natural frequency "
94
+ "needs poles like [-2, 3] and [-2, -3], both halves listed explicitly)."
95
+ ),
96
  parameters_schema={
97
  "type": "object",
98
  "properties": {
 
100
  "B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
101
  "desired_poles": {
102
  "type": "array",
103
+ "items": {
104
+ "oneOf": [
105
+ {"type": "number"},
106
+ {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
107
+ ]
108
+ },
109
+ "description": "Target closed-loop pole locations: a number for a real pole, or [real, imag] for a complex one.",
110
  },
111
  },
112
  "required": ["A", "B", "desired_poles"],
113
  },
114
  )
115
  def place_state_feedback(
116
+ A: list[list[float]], B: list[list[float]], desired_poles: list[float | list[float]]
117
  ) -> dict[str, Any]:
118
  A_mat = np.array(A, dtype=float)
119
  B_mat = np.array(B, dtype=float)
120
+ # Each pole is a plain number (real pole) or a [real, imag] pair.
121
+ desired_poles = [complex(p[0], p[1]) if isinstance(p, (list, tuple)) else complex(p) for p in desired_poles]
122
+ # dtype=complex, not float: targeting a specific damping ratio / natural
123
+ # frequency means passing a complex-conjugate pole pair, which is the
124
+ # normal case for a 2nd-order-or-higher design, not an edge case. Forcing
125
+ # float here silently discarded the imaginary part, turning a legitimate
126
+ # conjugate pair into the SAME real pole listed twice -- which then fails
127
+ # outright for a SISO system since place_poles cannot repeat a pole more
128
+ # than rank(B) times.
129
+ des = np.array(desired_poles, dtype=complex)
130
  placed = signal.place_poles(A_mat, B_mat, des)
131
+ # K is mathematically real for real A, B with a properly conjugate-paired
132
+ # desired_poles; drop the negligible numerical imaginary residue so the
133
+ # result is JSON-serializable (a genuinely unpaired complex pole is
134
+ # rejected by place_poles itself before this line is reached).
135
+ K = placed.gain_matrix.real
136
  closed_poles = np.linalg.eigvals(A_mat - B_mat @ K)
137
 
138
+ v_report = verifier.verify_pole_placement(A_mat, B_mat, K, list(des))
139
  return {
140
  "K": K.tolist(),
141
  "closed_loop_poles": [[float(p.real), float(p.imag)] for p in closed_poles],
142
+ "target_poles": [[float(p.real), float(p.imag)] for p in des],
143
  "verification": v_report,
144
  }
145
 
controlai_rag/index.py CHANGED
@@ -23,6 +23,27 @@ def tokenize_corpus(text: str) -> list[str]:
23
  return re.findall(r"\b\w+\b|[+\-*/^_]", text.lower())
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  class ControlRAGIndex:
27
  """Fast, local, offline search index over control engineering documents."""
28
 
@@ -30,7 +51,39 @@ class ControlRAGIndex:
30
  self.index_dir = index_dir
31
  self.chunks: list[dict[str, Any]] = []
32
  self.bm25: Any | None = None
 
33
  self._load_if_exists()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  def build_from_chunks(self, chunks: list[Chunk]) -> None:
36
  if BM25Okapi is None:
@@ -46,6 +99,9 @@ class ControlRAGIndex:
46
  self.chunks.extend(new_dict_chunks)
47
  corpus = [tokenize_corpus(c["text"]) for c in self.chunks]
48
  self.bm25 = BM25Okapi(corpus)
 
 
 
49
  self.save()
50
 
51
  def save(self) -> None:
@@ -78,21 +134,40 @@ class ControlRAGIndex:
78
  return []
79
 
80
  scores = self.bm25.get_scores(tokens)
81
- top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
  results = []
84
- for idx in top_indices:
85
- score = float(scores[idx])
86
- if score <= 0.0:
87
- break
88
  chunk = self.chunks[idx]
89
  if source_filter and source_filter.lower() not in chunk["source_path"].lower():
90
  continue
 
 
91
  results.append({
92
  "chunk_id": chunk["chunk_id"],
93
  "score": round(score, 3),
94
  "source": chunk["source_path"],
95
- "filename": chunk["metadata"].get("filename", "unknown"),
 
 
 
96
  "page": chunk["metadata"].get("page"),
97
  "text": chunk["text"],
98
  })
@@ -100,3 +175,107 @@ class ControlRAGIndex:
100
  break
101
 
102
  return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  return re.findall(r"\b\w+\b|[+\-*/^_]", text.lower())
24
 
25
 
26
+ # Only chunk body text is tokenized into BM25, so a query naming a source
27
+ # ("what does Nise say about stability") cannot match the book it refers to.
28
+ # These constants drive a post-retrieval boost for chunks whose *filename*
29
+ # matches a distinctive query term.
30
+ SOURCE_MATCH_BOOST = 1.6
31
+ # A filename token is only distinctive enough to boost on if it appears in at
32
+ # most this fraction of the indexed files (floored at 2 files, so the rule
33
+ # still works on a small corpus). An author surname like "nise" or "ogata"
34
+ # appears in exactly one file; generic domain words appear in many.
35
+ SOURCE_TOKEN_MAX_FILE_FRACTION = 0.02
36
+ # Words that identify the subject rather than a specific source. Even if the
37
+ # corpus grows large enough for these to slip under the fraction cutoff, they
38
+ # must never trigger a source boost.
39
+ GENERIC_SOURCE_TOKENS = {
40
+ "control", "controls", "systems", "system", "engineering", "theory",
41
+ "lecture", "lectures", "notes", "chapter", "solutions", "solution",
42
+ "exercise", "exercises", "book", "textbook", "txtbk", "edition", "vol",
43
+ "part", "final", "exam", "slides", "course", "intro", "introduction",
44
+ }
45
+
46
+
47
  class ControlRAGIndex:
48
  """Fast, local, offline search index over control engineering documents."""
49
 
 
51
  self.index_dir = index_dir
52
  self.chunks: list[dict[str, Any]] = []
53
  self.bm25: Any | None = None
54
+ self._distinctive_source_tokens: set[str] = set()
55
  self._load_if_exists()
56
+ self._build_source_token_map()
57
+
58
+ def _build_source_token_map(self) -> None:
59
+ """Index which filename tokens are distinctive enough to boost on.
60
+
61
+ Author surnames and title words unique to a few files ("nise", "ogata",
62
+ "kharitonov") identify a source; words common across the corpus do not.
63
+ """
64
+ if not self.chunks:
65
+ return
66
+ files_per_token: dict[str, set[str]] = {}
67
+ all_files: set[str] = set()
68
+ for chunk in self.chunks:
69
+ fname = str(chunk.get("metadata", {}).get("filename", ""))
70
+ if not fname:
71
+ continue
72
+ all_files.add(fname)
73
+ for token in set(tokenize_corpus(fname)):
74
+ files_per_token.setdefault(token, set()).add(fname)
75
+
76
+ if not all_files:
77
+ return
78
+ cutoff = max(2, int(len(all_files) * SOURCE_TOKEN_MAX_FILE_FRACTION))
79
+ self._distinctive_source_tokens = {
80
+ token
81
+ for token, files in files_per_token.items()
82
+ if len(files) <= cutoff
83
+ and len(token) > 2
84
+ and not token.isdigit()
85
+ and token not in GENERIC_SOURCE_TOKENS
86
+ }
87
 
88
  def build_from_chunks(self, chunks: list[Chunk]) -> None:
89
  if BM25Okapi is None:
 
99
  self.chunks.extend(new_dict_chunks)
100
  corpus = [tokenize_corpus(c["text"]) for c in self.chunks]
101
  self.bm25 = BM25Okapi(corpus)
102
+ # A newly uploaded document may introduce a new author/title, so the
103
+ # distinctive-source vocabulary has to be recomputed alongside BM25.
104
+ self._build_source_token_map()
105
  self.save()
106
 
107
  def save(self) -> None:
 
134
  return []
135
 
136
  scores = self.bm25.get_scores(tokens)
137
+ ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
138
+
139
+ # Re-rank a widened candidate pool so that a chunk from an explicitly
140
+ # named source can be promoted above a body-text-only BM25 match.
141
+ query_source_tokens = set(tokens) & self._distinctive_source_tokens
142
+ pool = ranked[: max(top_k * 10, 60)]
143
+ rescored: list[tuple[float, int]] = []
144
+ for idx in pool:
145
+ base = float(scores[idx])
146
+ if base <= 0.0:
147
+ continue
148
+ score = base
149
+ if query_source_tokens:
150
+ fname_tokens = set(tokenize_corpus(str(self.chunks[idx].get("metadata", {}).get("filename", ""))))
151
+ if query_source_tokens & fname_tokens:
152
+ score *= SOURCE_MATCH_BOOST
153
+ rescored.append((score, idx))
154
+ rescored.sort(key=lambda pair: pair[0], reverse=True)
155
 
156
  results = []
157
+ for score, idx in rescored:
 
 
 
158
  chunk = self.chunks[idx]
159
  if source_filter and source_filter.lower() not in chunk["source_path"].lower():
160
  continue
161
+ fname = chunk["metadata"].get("filename", "unknown")
162
+ source_name, is_published = display_source_name(fname)
163
  results.append({
164
  "chunk_id": chunk["chunk_id"],
165
  "score": round(score, 3),
166
  "source": chunk["source_path"],
167
+ "filename": fname,
168
+ # User-facing label -- never show the raw filename in an answer.
169
+ "source_name": source_name,
170
+ "is_published_work": is_published,
171
  "page": chunk["metadata"].get("page"),
172
  "text": chunk["text"],
173
  })
 
175
  break
176
 
177
  return results
178
+
179
+
180
+ # A single process-wide index instance. The agent, the retrieval tool, and the
181
+ # document-upload endpoint must all read and mutate the SAME in-memory object:
182
+ # with separate instances, a document uploaded through the web UI is written to
183
+ # disk but stays invisible to the running agent until the server restarts.
184
+ _shared_index: ControlRAGIndex | None = None
185
+
186
+
187
+ def get_shared_index(index_dir: Path = INDEX_DIR) -> ControlRAGIndex:
188
+ """Return the process-wide shared RAG index, loading it on first use."""
189
+ global _shared_index
190
+ if _shared_index is None:
191
+ _shared_index = ControlRAGIndex(index_dir)
192
+ return _shared_index
193
+
194
+
195
+ # --- Human-readable source names -------------------------------------------
196
+ # Indexed filenames carry private organisational cruft -- owner initials
197
+ # ("JD_", "B&B_"), course codes ("AMC", "PLMMR"), and scan artefacts
198
+ # ("txtbk", "DEFINITIVO", a doubled ".pdf.pdf"). Those must never reach a user
199
+ # as a citation, so every hit also carries a cleaned display name plus whether
200
+ # it is a published work (citable by author/title) or personal course notes
201
+ # (referred to generically).
202
+
203
+ _PUBLISHED_SOURCES: dict[str, str] = {
204
+ "norman s. nise - control systems engineering": "Nise, *Control Systems Engineering*",
205
+ "ogata modern control engineering 5th txtbk": "Ogata, *Modern Control Engineering* (5th ed.)",
206
+ }
207
+
208
+ # Course code -> the subject it stands for, so a generic remainder such as
209
+ # "CAM Course Notes Part 2" still resolves to something meaningful.
210
+ _COURSE_CODES: dict[str, str] = {
211
+ "SAS": "Safety in Automation Systems",
212
+ "AMC": "Advanced and Multivariable Control",
213
+ "CIR": "Control of Industrial Robots",
214
+ "PLMMR": "Perception, Localization and Mapping for Mobile Robots",
215
+ "NC": "Networked Control",
216
+ "CAM": "Computer-Aided Manufacturing",
217
+ "PSC": "Production Systems Control",
218
+ "MIDA": "Model Identification and Data Analysis",
219
+ "MIDA1": "Model Identification and Data Analysis",
220
+ "ACEHV": "Autonomous and Connected Electric/Hybrid Vehicles",
221
+ "ACHEV": "Autonomous and Connected Electric/Hybrid Vehicles",
222
+ "ACAV": "Autonomous and Connected Vehicles",
223
+ "DDCSD": "Data-Driven Control System Design",
224
+ "SACI": "Industrial Automation and Communication Systems",
225
+ "ICT": "Information and Communication Technology",
226
+ }
227
+
228
+ _OWNER_PREFIX_RE = re.compile(r"^(?:B&B|BBB|JD|LP|EC|AC|FG|RB|XX)[_\-\s]+", re.IGNORECASE)
229
+ _NOISE_RE = re.compile(
230
+ r"\b(?:txtbk|definitivo|margini\s+larghi|theory\s+notes|practice\s+notes|final)\b|\(.*?\)",
231
+ re.IGNORECASE,
232
+ )
233
+
234
+
235
+ def display_source_name(filename: str) -> tuple[str, bool]:
236
+ """Map an indexed filename to (display name, is_published_work)."""
237
+ if not filename:
238
+ return ("local reference", False)
239
+
240
+ stem = str(filename)
241
+ while True:
242
+ lowered = stem.lower()
243
+ for ext in (".pdf", ".md", ".txt", ".json", ".jsonl"):
244
+ if lowered.endswith(ext):
245
+ stem = stem[: -len(ext)]
246
+ break
247
+ else:
248
+ break
249
+
250
+ key = " ".join(stem.split()).lower()
251
+ if key in _PUBLISHED_SOURCES:
252
+ return (_PUBLISHED_SOURCES[key], True)
253
+
254
+ name = _OWNER_PREFIX_RE.sub("", stem)
255
+
256
+ # A leading all-caps token is a course code; swap it for its subject.
257
+ subject = ""
258
+ parts = name.replace("_", " ").split()
259
+ if parts:
260
+ head = parts[0].strip(":-").upper()
261
+ if head in _COURSE_CODES:
262
+ subject = _COURSE_CODES[head]
263
+ parts = parts[1:]
264
+ elif len(parts) > 1 and 2 <= len(head) <= 6 and head.isalpha() and parts[0].isupper():
265
+ parts = parts[1:]
266
+
267
+ remainder = _NOISE_RE.sub("", " ".join(parts))
268
+ remainder = " ".join(remainder.replace("_", " ").split()).strip(" -–—")
269
+
270
+ generic = remainder.lower() in {
271
+ "", "course notes", "lecture notes", "notes", "exercise sessions",
272
+ "summary", "course notes part 1", "course notes part 2", "lectures",
273
+ }
274
+ if subject and generic:
275
+ label = subject
276
+ elif subject and remainder:
277
+ label = subject if remainder.lower() in subject.lower() else f"{subject} - {remainder}"
278
+ else:
279
+ label = remainder or subject or "local reference"
280
+
281
+ return (f"{label} (course notes)", False)
scripts/eval_answer_quality.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Broad answer-quality smoke test across every control-engineering domain.
3
+
4
+ Retrieval tests (scripts/test_rag_knowledge.py) only prove the right passage was
5
+ found. This runs the FULL agent and grades the answer that actually reaches the
6
+ user, checking the failure modes seen in practice:
7
+
8
+ * rendering -- doubled LaTeX backslashes, orphaned <tool_call> tags,
9
+ raw JSON leaking into prose
10
+ * citations -- raw indexed filenames / extensions / course codes shown to
11
+ the user instead of clean source names
12
+ * substance -- the canned "analysis complete" placeholder, empty answers,
13
+ answers too short to be useful
14
+ * tool hygiene -- the same tool re-run over and over, failed tool calls
15
+
16
+ Usage:
17
+ python3 scripts/eval_answer_quality.py # all cases
18
+ python3 scripts/eval_answer_quality.py --filter robot # subset
19
+ python3 scripts/eval_answer_quality.py --limit 5
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import re
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
31
+ if str(PROJECT_ROOT) not in sys.path:
32
+ sys.path.insert(0, str(PROJECT_ROOT))
33
+
34
+ from controlai_agent.orchestrator import ControlAIAgent
35
+
36
+ PLACEHOLDER = "computational analysis has been completed"
37
+
38
+ # (domain, question). Deliberately spans applied domains and answer *shapes*:
39
+ # numeric-with-tool, conceptual-from-RAG, design/applied prose, and definitional.
40
+ CASES: list[tuple[str, str]] = [
41
+ ("classical", "Compute the gain margin, phase margin and crossover frequencies for G(s) = 10/(s*(s+1)*(s+5))."),
42
+ ("classical", "Explain what the phase margin tells you about a closed-loop system's damping."),
43
+ ("modern", "Design an LQR controller for A=[[0,1],[-2,-3]], B=[[0],[1]], Q=diag([10,1]), R=1 and simulate the step response."),
44
+ ("modern", "What is the difference between controllability and stabilizability?"),
45
+ ("mpc", "What is quasi-infinite horizon MPC and why is the terminal region needed?"),
46
+ ("mpc", "How does MPC handle actuator saturation compared to anti-windup PID?"),
47
+ ("estimation", "Explain the difference between the Kalman filter time update and measurement update."),
48
+ ("nonlinear", "What is a control barrier function and how does it enforce safety?"),
49
+ ("robust", "State the small gain theorem and when it is conservative."),
50
+ ("aerospace", "How is gain scheduling used in an aircraft flight control system across the flight envelope?"),
51
+ ("aerospace", "Explain the short-period and phugoid modes of aircraft longitudinal dynamics."),
52
+ ("automotive", "How does an electronic stability program use yaw rate feedback to correct oversteer?"),
53
+ ("automotive", "Design considerations for an adaptive cruise control spacing policy."),
54
+ ("robotics", "Explain impedance control for a robot manipulator in contact with the environment."),
55
+ ("robotics", "How does a mobile robot fuse odometry and lidar for localization?"),
56
+ ("automation", "What is cascade control in process automation and when does it help?"),
57
+ ("automation", "Explain integral windup in an industrial PID loop and how to prevent it."),
58
+ ("power", "How is field-oriented control used for induction motor drives?"),
59
+ ]
60
+
61
+ RAW_FILENAME_PAT = re.compile(
62
+ r"\.pdf\b|\.md\b|\.jsonl?\b|\bJD_|\bB&B_|\bBBB[_\s]|\bLP_|\bEC_|\bAC_|\bFG_|\bRB_|\bXX_|txtbk|DEFINITIVO",
63
+ re.IGNORECASE,
64
+ )
65
+ DOUBLE_BS_PAT = re.compile(r"\\\\(?=[a-zA-Z|{}()])")
66
+
67
+
68
+ def grade(question: str, result) -> tuple[list[str], dict]:
69
+ """Return (list of problems, metrics) for one answer."""
70
+ text = result.final_response or ""
71
+ problems: list[str] = []
72
+
73
+ if not text.strip():
74
+ problems.append("EMPTY answer")
75
+ elif PLACEHOLDER in text.lower():
76
+ problems.append("PLACEHOLDER non-answer")
77
+ elif len(text.split()) < 40:
78
+ problems.append(f"THIN answer ({len(text.split())} words)")
79
+
80
+ if DOUBLE_BS_PAT.search(text):
81
+ problems.append("DOUBLED LaTeX backslash")
82
+ if "<tool_call>" in text or "</tool_call>" in text:
83
+ problems.append("LEAKED tool_call tag")
84
+ if re.search(r'\{\s*"name"\s*:', text):
85
+ problems.append("LEAKED raw JSON")
86
+ if RAW_FILENAME_PAT.search(text):
87
+ problems.append("RAW filename in citation")
88
+
89
+ names = [t.tool_name for t in result.tool_traces]
90
+ failed = [t.tool_name for t in result.tool_traces if t.result.get("status") == "error"]
91
+ if failed:
92
+ problems.append(f"TOOL ERROR: {', '.join(sorted(set(failed)))}")
93
+ for n in set(names):
94
+ if names.count(n) > 2:
95
+ problems.append(f"REPEATED tool x{names.count(n)}: {n}")
96
+
97
+ return problems, {"tools": names, "words": len(text.split()), "plots": len(result.plots)}
98
+
99
+
100
+ def main() -> int:
101
+ ap = argparse.ArgumentParser(description=__doc__)
102
+ ap.add_argument("--filter", default="", help="only run cases whose domain or text matches")
103
+ ap.add_argument("--limit", type=int, default=0)
104
+ args = ap.parse_args()
105
+
106
+ cases = [c for c in CASES if args.filter.lower() in (c[0] + " " + c[1]).lower()]
107
+ if args.limit:
108
+ cases = cases[: args.limit]
109
+
110
+ print(f"Loading agent...\n")
111
+ agent = ControlAIAgent()
112
+
113
+ failures: list[tuple[str, str, list[str]]] = []
114
+ for i, (domain, q) in enumerate(cases, 1):
115
+ t0 = time.time()
116
+ try:
117
+ res = agent.run(q)
118
+ except Exception as exc: # a crash is itself a finding
119
+ failures.append((domain, q, [f"EXCEPTION: {exc}"]))
120
+ print(f"[{i}/{len(cases)}] {domain:<11} EXCEPTION {exc}")
121
+ continue
122
+ problems, meta = grade(q, res)
123
+ status = "ok " if not problems else "FAIL"
124
+ print(
125
+ f"[{i}/{len(cases)}] {domain:<11} {status} "
126
+ f"{time.time()-t0:5.1f}s words={meta['words']:<4} "
127
+ f"plots={meta['plots']} tools={','.join(meta['tools']) or '-'}"
128
+ )
129
+ for p in problems:
130
+ print(f" - {p}")
131
+ if problems:
132
+ failures.append((domain, q, problems))
133
+
134
+ print("\n" + "=" * 74)
135
+ print(f"{len(cases) - len(failures)}/{len(cases)} answers clean")
136
+ if failures:
137
+ print("\nIssues by type:")
138
+ counts: dict[str, int] = {}
139
+ for _, _, ps in failures:
140
+ for p in ps:
141
+ key = p.split(":")[0].split("(")[0].strip()
142
+ counts[key] = counts.get(key, 0) + 1
143
+ for k, v in sorted(counts.items(), key=lambda kv: -kv[1]):
144
+ print(f" {v:>3} {k}")
145
+ print("=" * 74)
146
+ return 0 if not failures else 1
147
+
148
+
149
+ if __name__ == "__main__":
150
+ raise SystemExit(main())
scripts/generate_behavior_sft_dataset.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate the behavioral SFT dataset the deployed adapter is missing.
3
+
4
+ The current adapter (sft_v2 + agent traces) was trained exclusively on
5
+ fully-specified control problems mapped to the matching design tool. As a
6
+ result the model:
7
+ * routes ANY matrix-shaped question to a control-design tool (asked to
8
+ multiply two matrices, it ran an LQR synthesis),
9
+ * fabricates missing parameters (invented B/Q/R three separate times)
10
+ because it never saw a single example of asking for a missing value,
11
+ * carries numbers over from a previous, different problem in multi-turn
12
+ conversations.
13
+
14
+ This script generates verified trajectories for exactly those behaviors:
15
+ 1. plain linear-algebra routing -> matrix_arithmetic tool traces
16
+ 2. missing-parameter refusal -> no tool call; state what's missing, ask
17
+ 3. poisoned-history faithfulness -> multi-turn: full problem in turn 1,
18
+ different request in turn 2 answered
19
+ WITHOUT reusing turn-1 numbers
20
+ 4. conceptual questions -> prose answer, no unnecessary code tool
21
+
22
+ Every numeric answer is produced by executing the real registered tool, so
23
+ the dataset is verified by construction.
24
+
25
+ Usage:
26
+ python3 scripts/generate_behavior_sft_dataset.py
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import random
33
+ import sys
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
38
+ if str(PROJECT_ROOT) not in sys.path:
39
+ sys.path.insert(0, str(PROJECT_ROOT))
40
+
41
+ import numpy as np
42
+
43
+ from controlai_agent.registry import registry
44
+ import controlai_agent.tools # noqa: F401 (register all tools)
45
+
46
+ OUTPUT_DIR = PROJECT_ROOT / "data" / "training" / "behavior_sft_v1"
47
+ RNG_SEED = 20260819
48
+
49
+ SYSTEM_PROMPT = (
50
+ "You are an offline control-systems engineering assistant. Lead with the result, "
51
+ "state assumptions and conventions, show the decisive calculation, and never invent "
52
+ "a plant, parameter, software output, or verification result. Use executable Python "
53
+ "or MATLAB only when requested."
54
+ )
55
+
56
+
57
+ def fmt_num(x: float) -> str:
58
+ return f"{x:.6g}"
59
+
60
+
61
+ def fmt_matrix(mat: list[list[float]]) -> str:
62
+ return "[" + ", ".join("[" + ", ".join(fmt_num(v) for v in row) + "]" for row in mat) + "]"
63
+
64
+
65
+ def fmt_matrix_latex(mat) -> str:
66
+ arr = np.atleast_2d(np.array(mat, dtype=float))
67
+ rows = [" & ".join(fmt_num(v) for v in row) for row in arr]
68
+ return "\\begin{bmatrix} " + " \\\\ ".join(rows) + " \\end{bmatrix}"
69
+
70
+
71
+ def rand_int_matrix(rng: random.Random, rows: int, cols: int) -> list[list[float]]:
72
+ return [[float(rng.randint(-9, 9)) for _ in range(cols)] for _ in range(rows)]
73
+
74
+
75
+ def rand_nonsingular(rng: random.Random, n: int) -> list[list[float]]:
76
+ while True:
77
+ m = rand_int_matrix(rng, n, n)
78
+ if abs(np.linalg.det(np.array(m))) > 0.5:
79
+ return m
80
+
81
+
82
+ def matrix_to_text(mat: list[list[float]], rng: random.Random) -> str:
83
+ """Render a matrix as the user might actually type it, garbling included."""
84
+ style = rng.random()
85
+ body = "[" + ", ".join("[" + ", ".join(fmt_num(v) for v in row) + "]" for row in mat) + "]"
86
+ if style < 0.7:
87
+ return body
88
+ # space-separated rows, occasionally with a dropped comma (real user input)
89
+ rows = []
90
+ for row in mat:
91
+ if rng.random() < 0.5:
92
+ rows.append("[" + " ".join(fmt_num(v) for v in row) + "]")
93
+ else:
94
+ rows.append("[" + ", ".join(fmt_num(v) for v in row) + "]")
95
+ return "[" + ", ".join(rows) + "]"
96
+
97
+
98
+ def tool_call_msg(name: str, args: dict[str, Any]) -> dict[str, str]:
99
+ return {
100
+ "role": "assistant",
101
+ "content": f"<tool_call>\n{json.dumps({'name': name, 'arguments': args}, ensure_ascii=False)}\n</tool_call>",
102
+ }
103
+
104
+
105
+ def tool_result_msg(name: str, result: dict[str, Any]) -> dict[str, str]:
106
+ return {"role": "tool", "name": name, "content": json.dumps(result, ensure_ascii=False)}
107
+
108
+
109
+ def make_example(messages: list[dict[str, str]]) -> dict[str, Any]:
110
+ return {"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + messages}
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # 1. Plain linear-algebra routing
115
+ # ---------------------------------------------------------------------------
116
+
117
+ MULT_PHRASES = [
118
+ "{a} times {b}",
119
+ "Multiply {a} by {b}",
120
+ "Compute the matrix product of A = {a} and B = {b}",
121
+ "A = {a} times A = {b}",
122
+ "What is {a} * {b}?",
123
+ "{a} multiplied by {b}",
124
+ ]
125
+
126
+ UNARY_TEMPLATES = {
127
+ "inverse": ["Invert the matrix {a}", "What is the inverse of {a}?", "Compute {a}^-1"],
128
+ "determinant": ["Determinant of {a}", "Compute det({a})", "What is the determinant of the matrix {a}?"],
129
+ "rank": ["What is the rank of {a}?", "Compute the rank of the matrix {a}"],
130
+ "transpose": ["Transpose {a}", "What is {a} transposed?"],
131
+ "eigenvalues": ["Eigenvalues of {a}", "Find the eigenvalues of the matrix {a}", "Compute the spectrum of {a}"],
132
+ "trace": ["What is the trace of {a}?", "Compute the trace of {a}"],
133
+ }
134
+
135
+
136
+ def gen_multiply(rng: random.Random) -> dict[str, Any] | None:
137
+ n = rng.choice([2, 2, 3, 3, 4])
138
+ k = rng.choice([n, n, rng.choice([1, 2, 3])])
139
+ A = rand_int_matrix(rng, n, k)
140
+ B = rand_int_matrix(rng, k, rng.choice([1, 2, 3, n]))
141
+ args = {"operation": "multiply", "matrix_a": A, "matrix_b": B}
142
+ result = registry.execute("matrix_arithmetic", args)
143
+ if result.get("status") != "success":
144
+ return None
145
+ prompt = rng.choice(MULT_PHRASES).format(a=matrix_to_text(A, rng), b=matrix_to_text(B, rng))
146
+ product = result["result"]
147
+ final = (
148
+ f"The matrix product is:\n$$AB = {fmt_matrix_latex(product)}$$\n"
149
+ f"({len(A)}x{len(A[0])} times {len(B)}x{len(B[0])} gives a {len(product)}x{len(product[0])} result; "
150
+ f"entry $(i,j)$ is the dot product of row $i$ of the first matrix with column $j$ of the second.)"
151
+ )
152
+ return make_example([
153
+ {"role": "user", "content": prompt},
154
+ tool_call_msg("matrix_arithmetic", args),
155
+ tool_result_msg("matrix_arithmetic", result),
156
+ {"role": "assistant", "content": final},
157
+ ])
158
+
159
+
160
+ def gen_unary(rng: random.Random) -> dict[str, Any] | None:
161
+ op = rng.choice(list(UNARY_TEMPLATES.keys()))
162
+ n = rng.choice([2, 2, 3, 3])
163
+ A = rand_nonsingular(rng, n) if op == "inverse" else (
164
+ rand_int_matrix(rng, n, n) if op in ("determinant", "eigenvalues", "trace") else rand_int_matrix(rng, n, rng.choice([n, n + 1]))
165
+ )
166
+ args = {"operation": op, "matrix_a": A}
167
+ result = registry.execute("matrix_arithmetic", args)
168
+ if result.get("status") != "success":
169
+ return None
170
+ prompt = rng.choice(UNARY_TEMPLATES[op]).format(a=matrix_to_text(A, rng))
171
+ res = result["result"]
172
+ if op == "inverse":
173
+ 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.)"
174
+ elif op == "transpose":
175
+ final = f"The transpose is:\n$$A^T = {fmt_matrix_latex(res)}$$"
176
+ elif op == "eigenvalues":
177
+ 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]
178
+ final = f"The eigenvalues are:\n$$\\lambda = \\{{{', '.join(eig_strs)}\\}}$$\nSpectral radius: $\\rho(A) = {fmt_num(result['spectral_radius'])}$."
179
+ elif op == "rank":
180
+ full = "full rank" if res == min(len(A), len(A[0])) else f"rank-deficient (max possible {min(len(A), len(A[0]))})"
181
+ final = f"$\\text{{rank}}(A) = {res}$ -- the matrix is {full}."
182
+ else:
183
+ final = f"$\\text{{{op}}}(A) = {fmt_num(res)}$."
184
+ return make_example([
185
+ {"role": "user", "content": prompt},
186
+ tool_call_msg("matrix_arithmetic", args),
187
+ tool_result_msg("matrix_arithmetic", result),
188
+ {"role": "assistant", "content": final},
189
+ ])
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # 2. Missing-parameter refusal (single-turn)
194
+ # ---------------------------------------------------------------------------
195
+
196
+ def gen_refusal_single(rng: random.Random) -> dict[str, Any]:
197
+ n = rng.choice([2, 3])
198
+ A = rand_int_matrix(rng, n, n)
199
+ kind = rng.choice(["lqr_no_b", "lqr_no_qr", "place_no_poles", "zoh_no_ts", "margins_no_tf", "kalman_no_cov"])
200
+
201
+ if kind == "lqr_no_b":
202
+ prompt = rng.choice([
203
+ f"Design an LQR controller for A={fmt_matrix(A)}.",
204
+ f"Solve the LQR problem for the system with state matrix A={fmt_matrix(A)} and simulate the closed loop.",
205
+ 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.",
206
+ ])
207
+ answer = (
208
+ f"I can't run the LQR synthesis yet: the input matrix $B$ ({n}x m) has not been provided, and I "
209
+ f"won't assume one -- the gain $K$ depends entirely on how the input enters the dynamics.\n\n"
210
+ f"What I can tell you from $A$ alone: the open-loop eigenvalues determine whether the plant is "
211
+ f"stable before feedback, and controllability of $(A, B)$ must be checked once $B$ is known.\n\n"
212
+ f"Please provide $B$ (and $Q$, $R$ if you want other than default weights), and I will compute "
213
+ f"$P$, $K$, and the closed-loop poles."
214
+ )
215
+ elif kind == "lqr_no_qr":
216
+ B = [[0.0] for _ in range(n - 1)] + [[1.0]]
217
+ prompt = f"Compute the LQR gain for A={fmt_matrix(A)}, B={fmt_matrix(B)}."
218
+ answer = (
219
+ "The LQR gain is not defined until the cost is: I need the state penalty $Q$ (positive "
220
+ "semidefinite, {n}x{n}) and the control penalty $R$ (positive definite). Different $Q, R$ give "
221
+ "completely different gains for the same plant.\n\nTell me $Q$ and $R$ -- or tell me what "
222
+ "behavior you want (e.g. fast state 1, cheap control) and I will propose weights and say so "
223
+ "explicitly as a design choice."
224
+ ).replace("{n}", str(n))
225
+ elif kind == "place_no_poles":
226
+ B = [[0.0] for _ in range(n - 1)] + [[1.0]]
227
+ prompt = f"Place the closed-loop poles for A={fmt_matrix(A)}, B={fmt_matrix(B)} using state feedback."
228
+ answer = (
229
+ "Pole placement needs target pole locations, and none were given. Where do you want the "
230
+ f"closed-loop poles? Provide {n} locations (complex ones in conjugate pairs, e.g. $-2 \\pm 3j$), "
231
+ "or give a specification (settling time, damping ratio) and I will derive pole locations from it "
232
+ "and state that derivation explicitly."
233
+ )
234
+ elif kind == "zoh_no_ts":
235
+ B = [[0.0] for _ in range(n - 1)] + [[1.0]]
236
+ prompt = f"Discretize A={fmt_matrix(A)}, B={fmt_matrix(B)} using zero-order hold."
237
+ answer = (
238
+ "Exact ZOH discretization requires the sampling period $T_s$, which was not given -- $A_d = "
239
+ "e^{A T_s}$ changes completely with it. What is $T_s$ (in seconds)? As a rule of thumb it should "
240
+ "give 10-30 samples per dominant closed-loop time constant."
241
+ )
242
+ elif kind == "margins_no_tf":
243
+ prompt = rng.choice([
244
+ "Compute the gain and phase margins of my loop.",
245
+ "What are the stability margins of the system?",
246
+ ])
247
+ answer = (
248
+ "I need the open-loop transfer function to compute margins -- numerator and denominator "
249
+ "coefficients of $L(s)$, or its poles/zeros/gain, or the state-space matrices. Please provide "
250
+ "one of those and I will compute GM, PM, and both crossover frequencies exactly."
251
+ )
252
+ else: # kalman_no_cov
253
+ prompt = f"Run a Kalman filter time update for A={fmt_matrix(A)}."
254
+ answer = (
255
+ "A Kalman time update needs more than $A$: the current state estimate $\\hat{x}$, its covariance "
256
+ "$P$, and the process noise covariance $Q_w$ (plus $B$ and $u$ if there is a known input). "
257
+ "Which values should I use? I will not assume noise statistics -- they determine the filter "
258
+ "entirely."
259
+ )
260
+ return make_example([
261
+ {"role": "user", "content": prompt},
262
+ {"role": "assistant", "content": answer},
263
+ ])
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # 3. Poisoned-history faithfulness (multi-turn)
268
+ # ---------------------------------------------------------------------------
269
+
270
+ def _full_lqr_turn(rng: random.Random) -> tuple[list[dict[str, str]], dict[str, Any]]:
271
+ """A complete verified LQR exchange to serve as turn 1 (the 'poison')."""
272
+ A = [[0.0, 1.0], [float(-rng.randint(1, 5)), float(-rng.randint(1, 5))]]
273
+ B = [[0.0], [1.0]]
274
+ q1, q2 = rng.choice([(10, 1), (20, 1), (5, 2), (100, 1)])
275
+ Q = [[float(q1), 0.0], [0.0, float(q2)]]
276
+ R = [[1.0]]
277
+ args = {"A": A, "B": B, "Q": Q, "R": R}
278
+ result = registry.execute("continuous_lqr", args)
279
+ prompt = f"Design an LQR controller for A={fmt_matrix(A)}, B={fmt_matrix(B)}, Q=diag([{q1}, {q2}]), R=1."
280
+ K = result["K"]
281
+ poles = result["closed_loop_poles"]
282
+ 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]
283
+ final = (
284
+ f"Solving the CARE gives the optimal gain $K = {fmt_matrix_latex(K)}$ with closed-loop poles "
285
+ f"$\\{{{', '.join(pole_strs)}\\}}$ -- the closed loop is asymptotically stable."
286
+ )
287
+ turn = [
288
+ {"role": "user", "content": prompt},
289
+ tool_call_msg("continuous_lqr", args),
290
+ tool_result_msg("continuous_lqr", result),
291
+ {"role": "assistant", "content": final},
292
+ ]
293
+ return turn, args
294
+
295
+
296
+ def gen_poisoned_matrix_question(rng: random.Random) -> dict[str, Any] | None:
297
+ """Turn 1: full LQR. Turn 2: plain matrix question -> matrix tool only."""
298
+ turn1, _ = _full_lqr_turn(rng)
299
+ n = rng.choice([2, 3])
300
+ X = rand_int_matrix(rng, n, n)
301
+ Y = rand_int_matrix(rng, n, n)
302
+ args = {"operation": "multiply", "matrix_a": X, "matrix_b": Y}
303
+ result = registry.execute("matrix_arithmetic", args)
304
+ if result.get("status") != "success":
305
+ return None
306
+ prompt = rng.choice(MULT_PHRASES).format(a=matrix_to_text(X, rng), b=matrix_to_text(Y, rng))
307
+ final = f"The matrix product is:\n$$XY = {fmt_matrix_latex(result['result'])}$$"
308
+ return make_example(turn1 + [
309
+ {"role": "user", "content": prompt},
310
+ tool_call_msg("matrix_arithmetic", args),
311
+ tool_result_msg("matrix_arithmetic", result),
312
+ {"role": "assistant", "content": final},
313
+ ])
314
+
315
+
316
+ def gen_poisoned_refusal(rng: random.Random) -> dict[str, Any]:
317
+ """Turn 1: full 2-state LQR. Turn 2: new 3-state A with no B -> refuse,
318
+ explicitly declining to reuse the old problem's matrices."""
319
+ turn1, old_args = _full_lqr_turn(rng)
320
+ A3 = rand_int_matrix(rng, 3, 3)
321
+ prompt = rng.choice([
322
+ f"Now design an LQR controller for A={fmt_matrix(A3)}.",
323
+ f"Do the same for A={fmt_matrix(A3)}.",
324
+ f"LQR for A={fmt_matrix(A3)} please.",
325
+ ])
326
+ answer = (
327
+ "This is a new 3-state system, and I only have its $A$ matrix. The $B$, $Q$, and $R$ from the "
328
+ "previous problem belong to a different 2-state plant -- they do not carry over, and resizing them "
329
+ "to fit would be inventing a model.\n\nTo run this synthesis I need: $B$ (3 x m), and the weights "
330
+ "$Q$ (3x3 PSD) and $R$ (m x m PD), or a description of the actuation and objectives so I can "
331
+ "propose weights as an explicit design choice. What is $B$ for this system?"
332
+ )
333
+ return make_example(turn1 + [
334
+ {"role": "user", "content": prompt},
335
+ {"role": "assistant", "content": answer},
336
+ ])
337
+
338
+
339
+ def gen_poisoned_concept_question(rng: random.Random) -> dict[str, Any]:
340
+ """Turn 1: full LQR. Turn 2: conceptual question -> prose, no tools, no old numbers."""
341
+ turn1, _ = _full_lqr_turn(rng)
342
+ concept, answer = rng.choice(CONCEPT_QA)
343
+ return make_example(turn1 + [
344
+ {"role": "user", "content": concept},
345
+ {"role": "assistant", "content": answer},
346
+ ])
347
+
348
+
349
+ # ---------------------------------------------------------------------------
350
+ # 4. Conceptual questions -> prose, no unnecessary tools
351
+ # ---------------------------------------------------------------------------
352
+
353
+ CONCEPT_QA: list[tuple[str, str]] = [
354
+ (
355
+ "What is the difference between controllability and stabilizability?",
356
+ "**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.",
357
+ ),
358
+ (
359
+ "Why does a phase margin around 45-60 degrees correspond to good damping?",
360
+ "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.",
361
+ ),
362
+ (
363
+ "What is integral windup in a PID loop and how is it prevented?",
364
+ "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.",
365
+ ),
366
+ (
367
+ "When would you choose MPC over a well-tuned PID controller?",
368
+ "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.",
369
+ ),
370
+ (
371
+ "What does the separation principle say for observer-based control?",
372
+ "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.",
373
+ ),
374
+ (
375
+ "Explain the waterbed effect in feedback design.",
376
+ "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.",
377
+ ),
378
+ ]
379
+
380
+
381
+ def gen_concept(rng: random.Random) -> dict[str, Any]:
382
+ q, a = rng.choice(CONCEPT_QA)
383
+ return make_example([
384
+ {"role": "user", "content": q},
385
+ {"role": "assistant", "content": a},
386
+ ])
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+
391
+
392
+ def main() -> None:
393
+ rng = random.Random(RNG_SEED)
394
+ examples: list[dict[str, Any]] = []
395
+
396
+ generators = [
397
+ (gen_multiply, 220),
398
+ (gen_unary, 200),
399
+ (gen_refusal_single, 260),
400
+ (gen_poisoned_matrix_question, 130),
401
+ (gen_poisoned_refusal, 130),
402
+ (gen_poisoned_concept_question, 60),
403
+ (gen_concept, 60),
404
+ ]
405
+ for fn, count in generators:
406
+ made = 0
407
+ attempts = 0
408
+ while made < count and attempts < count * 4:
409
+ attempts += 1
410
+ ex = fn(rng)
411
+ if ex is not None:
412
+ examples.append(ex)
413
+ made += 1
414
+ print(f"{fn.__name__}: {made}")
415
+
416
+ rng.shuffle(examples)
417
+ n_valid = max(1, len(examples) // 10)
418
+ valid, train = examples[:n_valid], examples[n_valid:]
419
+
420
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
421
+ for name, rows in (("train.jsonl", train), ("valid.jsonl", valid)):
422
+ with (OUTPUT_DIR / name).open("w", encoding="utf-8") as f:
423
+ for row in rows:
424
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
425
+ print(f"\nWrote {len(train)} train / {len(valid)} valid to {OUTPUT_DIR}")
426
+
427
+
428
+ if __name__ == "__main__":
429
+ main()
scripts/test_rag_knowledge.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Knowledge-retrieval smoke test for the local RAG index.
3
+
4
+ Runs a fixed set of canonical control-engineering queries against the live
5
+ BM25 index used by the agent (controlai_rag.index.ControlRAGIndex) and checks
6
+ that each query's top hits actually contain at least one expected keyword.
7
+ Also reports whether each query's best score clears the 2.5 relevance
8
+ threshold that ControlAIAgent._get_grounded_instruction uses to decide
9
+ whether to inject retrieved text into the system prompt -- a query can
10
+ retrieve "correct" chunks yet still never get grounded into an answer if its
11
+ score sits below that bar.
12
+
13
+ Usage:
14
+ python3 scripts/test_rag_knowledge.py
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
23
+ if str(PROJECT_ROOT) not in sys.path:
24
+ sys.path.insert(0, str(PROJECT_ROOT))
25
+
26
+ from controlai_rag.index import ControlRAGIndex
27
+
28
+ GROUNDING_SCORE_THRESHOLD = 2.5
29
+
30
+ # (query, keywords where at least one must appear in a top-k hit's text)
31
+ # ControlAI is a general control-engineering agent, so this suite deliberately
32
+ # spans every application domain -- aerospace, automotive, robotics, industrial
33
+ # automation, power -- not just classical/modern theory.
34
+ TEST_CASES: list[tuple[str, list[str]]] = [
35
+ # --- Core theory ---
36
+ ("controllability matrix rank test", ["controllab", "rank"]),
37
+ ("observability of linear time invariant systems", ["observ"]),
38
+ ("continuous algebraic Riccati equation LQR", ["riccati", "lqr", "quadratic"]),
39
+ ("discrete algebraic Riccati equation DARE", ["riccati", "discrete"]),
40
+ ("zero order hold ZOH discretization", ["zero-order", "zero order", "hold", "discret"]),
41
+ ("Lyapunov stability of nonlinear systems", ["lyapunov", "stab"]),
42
+ ("gain margin phase margin frequency response", ["gain margin", "phase margin"]),
43
+ ("Kalman filter state estimation", ["kalman", "estimat"]),
44
+ ("PID controller tuning", ["pid", "proportional"]),
45
+ ("root locus method", ["root locus"]),
46
+ ("PBH test for controllability", ["pbh", "popov"]),
47
+ ("model predictive control constrained optimization", ["model predictive", "mpc", "horizon"]),
48
+ ("H-infinity robust control small gain theorem", ["h-infinity", "h infinity", "small gain", "hinf"]),
49
+ ("control barrier function safety filter", ["barrier", "safety"]),
50
+ ("state feedback pole placement", ["pole placement", "state feedback"]),
51
+ # --- Application domains ---
52
+ ("aircraft flight control longitudinal dynamics", ["aircraft", "flight", "longitudinal", "pitch"]),
53
+ ("quadrotor UAV attitude control", ["quadrotor", "uav", "attitude", "drone"]),
54
+ ("vehicle dynamics yaw rate stability control", ["vehicle", "yaw", "tire", "steering"]),
55
+ ("automotive cruise control design", ["cruise", "vehicle", "throttle", "speed"]),
56
+ ("robot manipulator kinematics and Jacobian", ["manipulator", "jacobian", "kinematic", "robot"]),
57
+ ("mobile robot localization and odometry", ["odometry", "localiz", "mobile robot", "slam"]),
58
+ ("industrial process control valve saturation", ["valve", "process", "saturat", "actuator"]),
59
+ ("cascade control loop in process automation", ["cascade", "process", "inner loop", "secondary"]),
60
+ ("electric motor drive speed control", ["motor", "drive", "torque", "induction"]),
61
+ ("system identification from input output data", ["identification", "arx", "least squares", "estimat"]),
62
+ ]
63
+
64
+
65
+ def run() -> int:
66
+ index = ControlRAGIndex()
67
+ if not index.chunks or not index.bm25:
68
+ print("FAIL: RAG index did not load (no chunks / no BM25 model). Is data/rag_index/ populated?")
69
+ return 1
70
+
71
+ print(f"RAG index loaded: {len(index.chunks)} chunks\n")
72
+
73
+ passed = 0
74
+ grounded = 0
75
+ for query, keywords in TEST_CASES:
76
+ hits = index.search(query, top_k=5)
77
+ best_score = hits[0]["score"] if hits else 0.0
78
+ matched = any(
79
+ kw.lower() in hit["text"].lower()
80
+ for hit in hits
81
+ for kw in keywords
82
+ )
83
+ would_ground = best_score > GROUNDING_SCORE_THRESHOLD
84
+ grounded += int(would_ground)
85
+ passed += int(matched)
86
+
87
+ status = "PASS" if matched else "FAIL"
88
+ ground_tag = "grounds" if would_ground else "below threshold"
89
+ print(f"[{status}] '{query}' best_score={best_score:.2f} ({ground_tag})")
90
+ if hits:
91
+ top = hits[0]
92
+ excerpt = " ".join(top["text"].split())[:160]
93
+ print(f" top hit: [{top['filename']} p.{top['page']}] {excerpt}...")
94
+ else:
95
+ print(" no hits returned")
96
+ print()
97
+
98
+ total = len(TEST_CASES)
99
+ print("=" * 70)
100
+ print(f"Keyword relevance: {passed}/{total} queries retrieved an on-topic chunk")
101
+ print(f"Grounding trigger: {grounded}/{total} queries would clear the score>{GROUNDING_SCORE_THRESHOLD} auto-grounding bar")
102
+ print("=" * 70)
103
+
104
+ return 0 if passed == total else 1
105
+
106
+
107
+ if __name__ == "__main__":
108
+ raise SystemExit(run())
web/app.css CHANGED
@@ -517,7 +517,7 @@ body {
517
 
518
  .code-block-wrapper pre code {
519
  font-family: 'JetBrains Mono', 'Menlo', monospace !important;
520
- font-size: 13px !important;
521
  line-height: 1.6 !important;
522
  background: transparent !important;
523
  padding: 0 !important;
@@ -549,12 +549,30 @@ body {
549
  }
550
 
551
  .generating-indicator {
552
- display: inline-block;
 
 
553
  color: var(--text-muted);
554
  font-family: var(--font-mono);
555
  font-size: 12px;
556
  }
557
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  .thought-summary {
559
  padding: 6px 12px;
560
  font-family: var(--font-mono);
@@ -590,7 +608,7 @@ body {
590
  padding: 10px 14px;
591
  color: var(--text-muted);
592
  font-family: var(--font-mono);
593
- font-size: 11px;
594
  line-height: 1.6;
595
  border-top: 1px solid var(--border-color);
596
  white-space: pre-wrap;
@@ -630,7 +648,7 @@ body {
630
 
631
  .plot-caption {
632
  font-family: var(--font-mono);
633
- font-size: 11px;
634
  color: var(--text-muted);
635
  margin-top: 6px;
636
  }
 
517
 
518
  .code-block-wrapper pre code {
519
  font-family: 'JetBrains Mono', 'Menlo', monospace !important;
520
+ font-size: calc(var(--chat-font-size) * 0.87) !important;
521
  line-height: 1.6 !important;
522
  background: transparent !important;
523
  padding: 0 !important;
 
549
  }
550
 
551
  .generating-indicator {
552
+ display: inline-flex;
553
+ align-items: center;
554
+ gap: 6px;
555
  color: var(--text-muted);
556
  font-family: var(--font-mono);
557
  font-size: 12px;
558
  }
559
 
560
+ .generating-indicator .dot {
561
+ width: 4px;
562
+ height: 4px;
563
+ border-radius: 50%;
564
+ background: currentColor;
565
+ animation: generating-pulse 1.1s ease-in-out infinite;
566
+ }
567
+
568
+ .generating-indicator .dot:nth-child(2) { animation-delay: 0.15s; }
569
+ .generating-indicator .dot:nth-child(3) { animation-delay: 0.3s; }
570
+
571
+ @keyframes generating-pulse {
572
+ 0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
573
+ 30% { opacity: 1; transform: translateY(-2px); }
574
+ }
575
+
576
  .thought-summary {
577
  padding: 6px 12px;
578
  font-family: var(--font-mono);
 
608
  padding: 10px 14px;
609
  color: var(--text-muted);
610
  font-family: var(--font-mono);
611
+ font-size: calc(var(--chat-font-size) * 0.73);
612
  line-height: 1.6;
613
  border-top: 1px solid var(--border-color);
614
  white-space: pre-wrap;
 
648
 
649
  .plot-caption {
650
  font-family: var(--font-mono);
651
+ font-size: calc(var(--chat-font-size) * 0.73);
652
  color: var(--text-muted);
653
  margin-top: 6px;
654
  }
web/app.js CHANGED
@@ -327,14 +327,22 @@ document.addEventListener('DOMContentLoaded', () => {
327
  }
328
 
329
  function deleteSession(id) {
 
330
  sessions = sessions.filter((s) => s.id !== id);
331
  saveSessions();
332
- if (currentSessionId === id) {
333
- currentSessionId = sessions.length > 0 ? sessions[0].id : null;
334
- if (!currentSessionId) {
335
- startNewSession();
336
- return;
337
- }
 
 
 
 
 
 
 
338
  }
339
  renderHistorySidebar();
340
  renderActiveSession();
@@ -453,6 +461,10 @@ document.addEventListener('DOMContentLoaded', () => {
453
  return entry;
454
  }
455
 
 
 
 
 
456
  function escapeHtml(text) {
457
  const div = document.createElement('div');
458
  div.textContent = text;
@@ -618,7 +630,7 @@ document.addEventListener('DOMContentLoaded', () => {
618
  botEntry.className = 'message-entry bot';
619
  botEntry.innerHTML = `
620
  <div class="message-label">ControlAI</div>
621
- <div class="message-content"><span class="generating-indicator">Working...</span></div>
622
  `;
623
  chatThread.appendChild(botEntry);
624
  smartScroll();
@@ -675,7 +687,7 @@ document.addEventListener('DOMContentLoaded', () => {
675
  const thoughtHtml = renderThoughtBox(thoughts, true);
676
  const textHtml = accumulatedText
677
  ? `<div class="response-body">${renderMarkdownWithKaTeX(accumulatedText)}<span class="streaming-cursor"></span></div>`
678
- : `<div class="response-body"><span class="generating-indicator">Working...</span><span class="streaming-cursor"></span></div>`;
679
  contentBox.innerHTML = thoughtHtml + textHtml;
680
  smartScroll();
681
  } else if (event.type === 'token') {
 
327
  }
328
 
329
  function deleteSession(id) {
330
+ const wasCurrent = currentSessionId === id;
331
  sessions = sessions.filter((s) => s.id !== id);
332
  saveSessions();
333
+
334
+ if (!wasCurrent) {
335
+ // Deleting a different chat must never touch the thread that's on
336
+ // screen -- rebuilding it here would wipe an in-progress streaming
337
+ // response out from under itself.
338
+ renderHistorySidebar();
339
+ return;
340
+ }
341
+
342
+ currentSessionId = sessions.length > 0 ? sessions[0].id : null;
343
+ if (!currentSessionId) {
344
+ startNewSession();
345
+ return;
346
  }
347
  renderHistorySidebar();
348
  renderActiveSession();
 
461
  return entry;
462
  }
463
 
464
+ function workingIndicatorHtml(label) {
465
+ return `<span class="generating-indicator">${label}<span class="dot"></span><span class="dot"></span><span class="dot"></span></span>`;
466
+ }
467
+
468
  function escapeHtml(text) {
469
  const div = document.createElement('div');
470
  div.textContent = text;
 
630
  botEntry.className = 'message-entry bot';
631
  botEntry.innerHTML = `
632
  <div class="message-label">ControlAI</div>
633
+ <div class="message-content">${workingIndicatorHtml('Working')}</div>
634
  `;
635
  chatThread.appendChild(botEntry);
636
  smartScroll();
 
687
  const thoughtHtml = renderThoughtBox(thoughts, true);
688
  const textHtml = accumulatedText
689
  ? `<div class="response-body">${renderMarkdownWithKaTeX(accumulatedText)}<span class="streaming-cursor"></span></div>`
690
+ : `<div class="response-body">${workingIndicatorHtml('Working')}<span class="streaming-cursor"></span></div>`;
691
  contentBox.innerHTML = thoughtHtml + textHtml;
692
  smartScroll();
693
  } else if (event.type === 'token') {
web/index.html CHANGED
@@ -25,7 +25,7 @@
25
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/cpp.min.js"></script>
26
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
27
 
28
- <link rel="stylesheet" href="/static/app.css?v=20260818_syntax_highlight">
29
  </head>
30
  <body>
31
  <div class="app-shell">
@@ -226,6 +226,6 @@
226
  </div>
227
  </div>
228
 
229
- <script src="/static/app.js?v=20260819_alphanumeric_math_fix"></script>
230
  </body>
231
  </html>
 
25
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/cpp.min.js"></script>
26
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
27
 
28
+ <link rel="stylesheet" href="/static/app.css?v=20260819_ui_fixes">
29
  </head>
30
  <body>
31
  <div class="app-shell">
 
226
  </div>
227
  </div>
228
 
229
+ <script src="/static/app.js?v=20260819_ui_fixes"></script>
230
  </body>
231
  </html>