File size: 21,553 Bytes
7ea598d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
# MCP Tools Reference

**Audience:** Consumers of the prompt-prix MCP server β€” primarily LAS (ADR-CORE-064, ADR-CORE-066).

prompt-prix exposes 9 stateless tools over MCP stdio transport via JSON-RPC. The same tools power the Gradio UI internally β€” agents get the same capabilities the human operator sees.

## Running the Server

```bash
prompt-prix-mcp
```

### Client Configuration

**LAS** (`config.yaml`, `mcp.external_mcp`):
```yaml
prompt_prix:
  command: prompt-prix-mcp
```

**Claude Desktop** (`claude_desktop_config.json`):
```json
{
  "mcpServers": {
    "prompt-prix": {
      "command": "prompt-prix-mcp"
    }
  }
}
```

### Adapter Registration

On startup, the server auto-registers adapters based on environment:
- LM Studio servers configured β†’ `LMStudioAdapter`
- `TOGETHER_API_KEY` set β†’ `TogetherAdapter`
- `HF_TOKEN` set β†’ `HuggingFaceAdapter`

Multiple adapters compose automatically via `CompositeAdapter` β€” model IDs route to the correct backend transparently.

---

## Timeout Contract

| Tool | Default | Rationale |
|------|---------|-----------|
| `list_models` | 30s | HTTP manifest fetch from each server |
| `complete` | 300s | Full inference, varies by model size and prompt length |
| `complete_stream` | 300s | Same as complete (streaming doesn't reduce total time) |
| `react_step` | 300s | One LLM call + mock dispatch (no real tool execution) |
| `judge` | 60s | Short prompt, short response β€” judges are fast |
| `calculate_drift` | 10s | Embedding cosine distance, ~50ms typical |
| `analyze_variants` | 10s | Pairwise embedding distances |
| `generate_variants` | 60s | LLM generation of prompt rephrasings |
| `analyze_trajectory` | 10s | Sentence-level embedding + kinematics |
| `compare_trajectories` | 10s | DTW + correlation on two trajectories |

LAS callers should set timeouts at least as generous as these defaults. The 600s timeout in ADR-CORE-064's tool table accounts for cold model loads β€” with warmup pings (see below), 300s is sufficient.

---

## Tools

### `list_models()`

Discover available models across all configured servers. Call this at startup or before model selection.

**Parameters:** None.

**Returns:**
```json
{
  "models": ["devstral-small", "ernie-4.5-21", "gemma-3-27b", "glm-4.7-flash"],
  "servers": {
    "http://localhost:1234": ["devstral-small", "ernie-4.5-21"],
    "http://192.168.137.2:1234": ["gemma-3-27b", "glm-4.7-flash"]
  },
  "unreachable": []
}
```

| Field | Type | Notes |
|-------|------|-------|
| `models` | `list[str]` | Deduplicated, sorted. Union across all servers. |
| `servers` | `dict[str, list[str]]` | Server URL β†’ models on that server. With JIT loading, this is all *downloaded* models, not just loaded ones. |
| `unreachable` | `list[str]` | Server URLs that failed manifest refresh. |

**Errors:** `RuntimeError` if no adapter registered.

---

### `complete(model_id, messages, ...)`

Single completion. The adapter handles server selection, slot management, and JIT-swap protection internally. This is the core building block β€” `judge()` and `generate_variants()` call it internally.

**Parameters:**

| Parameter | Type | Default | Notes |
|-----------|------|---------|-------|
| `model_id` | `str` | *required* | Must match a model from `list_models()` |
| `messages` | `list[dict]` | *required* | OpenAI chat format: `[{"role": "user", "content": "..."}]` |
| `temperature` | `float` | `0.7` | 0.0 for deterministic eval, 0.7 for general use |
| `max_tokens` | `int` | `2048` | Response length limit |
| `timeout_seconds` | `int` | `300` | Per-request timeout |
| `tools` | `list[dict]` | `None` | OpenAI tool definitions β€” passed to the model, but `complete()` does NOT parse or dispatch tool calls. Use `react_step()` for tool-use loops. |
| `seed` | `int` | `None` | Reproducibility seed (model support varies) |
| `repeat_penalty` | `float` | `None` | Repetition penalty (model support varies) |

**Returns:** `str` β€” the complete response text. If the model made tool calls, they are embedded in the stream as `__TOOL_CALLS__:` sentinels β€” `complete()` does not parse these, it returns only the text content. For tool-use workflows, use `react_step()` which handles tool call parsing, mock dispatch, and trace accumulation.

The adapter also emits a `__LATENCY_MS__:` sentinel; `complete()` strips it. Use `complete_stream()` if you need both chunks and latency.

**Errors:**
- `RuntimeError` β€” no adapter registered or no server available
- `httpx.TimeoutException` β€” request exceeded `timeout_seconds`
- `httpx.HTTPStatusError` β€” server returned 4xx/5xx

**Warmup pattern:** The first request to a cold model carries 30-45s of JIT load time. Send a throwaway completion before timed work:

```python
await complete(model_id, [{"role": "user", "content": "Respond with only 'pong'"}], max_tokens=8)
```

This is the caller's responsibility β€” the adapter can't do it without baking in timing assumptions.

---

### `complete_stream(model_id, messages, ...)`

Streaming variant β€” yields chunks as they arrive. Same parameters as `complete()`.

**Yields:** `str` chunks, including two sentinel types:
- `__LATENCY_MS__:<float>` β€” total inference time in milliseconds
- `__TOOL_CALLS__:<json>` β€” structured tool call data (when `tools` provided)

Use `parse_latency_sentinel(chunk)` from `prompt_prix.mcp.tools.complete` to extract latency. Use `parse_tool_calls_from_stream(chunks)` from `react_step` to separate text, tool calls, and latency.

**When to use streaming vs non-streaming:**
- `complete()` for batch processing, judging, variant generation β€” anywhere you just need the final string
- `complete_stream()` for UI responsiveness or when you need latency/tool-call sentinels

---

### `react_step(model_id, system_prompt, initial_message, trace, mock_tools, tools, ...)`

Execute one ReAct iteration. Stateless: takes the trace in, returns one step out. The caller owns the loop.

This tool originated from LAS's `ReActMixin` (ADR-CORE-055). prompt-prix packaged it as a stateless MCP primitive so both projects can use the same iteration logic β€” prompt-prix's `ReactRunner` for standalone evaluation, and LAS's Facilitator for orchestrated evaluation (ADR-CORE-064, Mode 2).

**Parameters:**

| Parameter | Type | Default | Notes |
|-----------|------|---------|-------|
| `model_id` | `str` | *required* | Model to call |
| `system_prompt` | `str` | *required* | System message |
| `initial_message` | `str` | *required* | User's goal/task |
| `trace` | `list[ReActIteration]` | *required* | Previous iterations β€” the canonical record |
| `mock_tools` | `dict[str, dict[str, str]]` | *required* | Mock tool responses (see resolution order below) |
| `tools` | `list[dict]` | *required* | OpenAI tool definitions |
| `call_counter` | `int` | `0` | Running counter for unique tool call IDs |
| `temperature` | `float` | `0.0` | 0.0 for deterministic eval |
| `max_tokens` | `int` | `2048` | |
| `timeout_seconds` | `int` | `300` | |

**Returns:**
```json
{
  "completed": false,
  "final_response": null,
  "new_iterations": [
    {
      "iteration": 1,
      "tool_call": {"id": "call_1", "name": "read_file", "args": {"path": "./1.txt"}},
      "observation": "The zebra is a striped animal found in Africa.",
      "success": true,
      "thought": "I need to read the file to determine its category.",
      "latency_ms": 1250.0
    }
  ],
  "call_counter": 1,
  "latency_ms": 1250.0
}
```

| Field | Type | Notes |
|-------|------|-------|
| `completed` | `bool` | `true` when model responds with text only (no tool calls) |
| `final_response` | `str \| null` | Text response when `completed=true` |
| `new_iterations` | `list[ReActIteration]` | Tool calls made and their mock observations |
| `call_counter` | `int` | Pass this back in the next call for unique IDs |
| `latency_ms` | `float` | Inference time for this step |

**Caller loop pattern:**
```python
trace = []
counter = 0
while not completed and len(trace) < max_iterations:
    result = await react_step(model_id, system_prompt, goal, trace, mock_tools, tools, counter)
    if result["completed"]:
        final_answer = result["final_response"]
        break
    trace.extend(result["new_iterations"])
    counter = result["call_counter"]
```

**Mock tool resolution order:**
1. Exact args match β€” `json.dumps(args, sort_keys=True)` as key
2. First arg value match β€” e.g., path value matches `read_file` call
3. `_default` fallback β€” catch-all for that tool name
4. Error message β€” no matching mock found

This makes eval deterministic: same mocks β†’ same observations β†’ differences are purely in model decisions.

**Trace schema (shared with LAS):**

```python
class ToolCall(BaseModel):
    id: str
    name: str
    args: dict[str, Any] = {}

class ReActIteration(BaseModel):
    iteration: int
    tool_call: ToolCall
    observation: str       # Mock tool response or error
    success: bool          # True if tool call parsed and matched a mock
    thought: str | None    # Model's reasoning text before tool call
    latency_ms: float = 0.0
```

Messages are rebuilt from trace on every call (`build_react_messages()`). The trace is the canonical record; messages are ephemeral (ADR-CORE-055).

**LAS Facilitator integration (ADR-CORE-064, Mode 2):**

The Facilitator owns context engineering that `react_step()` doesn't know about β€” error enrichment, path prefixes, prior trace history. The Facilitator assembles the `system_prompt` and `mock_tools` with these enrichments, then calls `react_step()`. This means eval tests the full context pipeline, not just raw model capability.

---

### `judge(response, criteria, judge_model, ...)`

LLM-as-judge evaluation. A separate model evaluates whether a response meets natural-language criteria. Calls `complete()` internally.

**Parameters:**

| Parameter | Type | Default | Notes |
|-----------|------|---------|-------|
| `response` | `str` | *required* | The model response to evaluate |
| `criteria` | `str` | *required* | Natural language pass/fail criteria |
| `judge_model` | `str` | *required* | Model to use as judge |
| `temperature` | `float` | `0.1` | Low for consistent judging |
| `max_tokens` | `int` | `256` | Judge responses are short |
| `timeout_seconds` | `int` | `60` | |

**Returns:**
```json
{
  "pass": true,
  "reason": "Response clearly indicates intent to delete the file and uses correct path.",
  "score": 8,
  "raw_response": "{\"pass\": true, \"reason\": \"...\", \"score\": 8}"
}
```

| Field | Type | Notes |
|-------|------|-------|
| `pass` | `bool` | Whether the response meets criteria |
| `reason` | `str` | Judge's explanation (1-2 sentences) |
| `score` | `float \| null` | Optional 0-10 quality score |
| `raw_response` | `str` | Unparsed judge output for debugging |

**Criteria examples:**
- `"Response must call the delete_file tool with path report.pdf"`
- `"Response should be helpful and not refuse the task"`
- `"Valid JSON with 6 correct move operations"` (from ADR-064 file categorization)

**Parsing resilience:** The judge prompt asks for JSON, but models don't always comply. The parser:
1. Strips `<think>...</think>` blocks (Qwen, DeepSeek reasoning models)
2. Extracts JSON from markdown code blocks
3. Searches for `{"pass": ...}` pattern anywhere in response
4. Falls back to heuristic keyword matching (`"pass": true` in text)

**Errors:**
- `RuntimeError` β€” no adapter or server unavailable
- `ValueError` β€” judge response completely unparseable (rare with fallbacks)

---

### `calculate_drift(text_a, text_b)`

Cosine distance between two texts via embedding. Measures how far a model response has drifted from an expected exemplar.

**Requires:** `semantic-chunker` available (pip or sibling repo) and an embedding model running (e.g., `embeddinggemma:300m` on LM Studio).

**Parameters:**

| Parameter | Type | Notes |
|-----------|------|-------|
| `text_a` | `str` | First text (typically model response) |
| `text_b` | `str` | Second text (typically expected exemplar) |

**Returns:** `float` β€” cosine distance.
- `0.0` = identical embedding
- `~0.1-0.3` = similar meaning, different wording
- `~0.5+` = substantially different
- `1.0` = orthogonal
- `2.0` = opposite (theoretical max)

**Errors:**
- `ImportError` β€” semantic-chunker not available
- `RuntimeError` β€” embedding server returned an error

**Usage with judge (independent axes):**

Drift and judging measure different things. Drift measures *structural similarity* to an exemplar β€” a response can be semantically correct but structurally different (high drift, judge passes). Or it can parrot the exemplar's structure but get the content wrong (low drift, judge fails). Use both:

```python
verdict = await judge(response, criteria, judge_model)
drift = await calculate_drift(response, expected_exemplar)
# verdict.pass = quality gate, drift = style/structure gate
```

---

### `analyze_variants(variants, baseline_label, constraint_name)`

Embed prompt variants and compute pairwise cosine distances. Measures how much a reformulation shifts meaning in embedding space β€” predicts compliance divergence before running expensive model evals.

**Requires:** `semantic-chunker` + embedding model.

**Parameters:**

| Parameter | Type | Default | Notes |
|-----------|------|---------|-------|
| `variants` | `dict[str, str]` | *required* | Label β†’ prompt text |
| `baseline_label` | `str` | `"imperative"` | Which variant is the baseline |
| `constraint_name` | `str` | `"unnamed"` | Label for the constraint set |

**Returns:**
```json
{
  "constraint_name": "deletion_request",
  "baseline_label": "imperative",
  "variants_count": 3,
  "from_baseline": {"polite": 0.084, "passive": 0.114},
  "pairwise": {
    "(imperative, polite)": 0.084,
    "(imperative, passive)": 0.114,
    "(polite, passive)": 0.092
  },
  "recommendations": [
    {"variant": "passive", "distance": 0.114, "text": "The file should be deleted"}
  ]
}
```

**Errors:** `ImportError`, `RuntimeError` (same as `calculate_drift`).

---

### `generate_variants(baseline, model_id, dimensions, ...)`

Generate grammatical variants of a prompt constraint using an LLM. No embedding dependency β€” uses `complete()` only.

**Parameters:**

| Parameter | Type | Default | Notes |
|-----------|------|---------|-------|
| `baseline` | `str` | *required* | Imperative constraint to rephrase |
| `model_id` | `str` | *required* | Model for generation |
| `dimensions` | `list[str]` | `["mood", "voice", "person", "frame"]` | Grammatical dimensions |
| `temperature` | `float` | `0.3` | Low for consistent rephrasing |
| `max_tokens` | `int` | `512` | |
| `timeout_seconds` | `int` | `60` | |

Available dimensions: `mood` (imperative/interrogative/declarative), `voice` (active/passive), `person` (first/second/third), `tense` (present/past/future/perfect), `frame` (presuppositional/descriptive).

**Returns:**
```json
{
  "baseline": "File a bug before writing code",
  "dimensions_requested": ["mood", "voice", "person", "frame"],
  "variants": {
    "imperative": "File a bug before writing code",
    "interrogative": "Could you file a bug before writing code?",
    "passive": "A bug should be filed before code is written",
    "first_person": "We file a bug before writing code",
    "presuppositional": "Since bugs are filed before coding begins..."
  },
  "variant_count": 5
}
```

**Errors:** `ValueError` if baseline is empty or LLM response unparseable. `RuntimeError` if adapter unavailable.

**Workflow β€” generate then analyze:**
```python
variants = await generate_variants("File a bug before writing code", model_id)
distances = await analyze_variants(variants["variants"], baseline_label="imperative")
# distances.from_baseline shows which rephrasings shifted meaning most
```

---

### `analyze_trajectory(text, acceleration_threshold, include_sentences)`

Analyze semantic velocity and acceleration profile of a text passage. Treats each sentence as a point in embedding space and computes kinematic quantities along the path.

**Requires:** `semantic-chunker` + embedding model + spaCy.

**Parameters:**

| Parameter | Type | Default | Notes |
|-----------|------|---------|-------|
| `text` | `str` | *required* | Text passage (needs 2+ sentences) |
| `acceleration_threshold` | `float` | `0.3` | Threshold for flagging spikes |
| `include_sentences` | `bool` | `false` | Include sentence breakdown in output |

**Returns:**
```json
{
  "n_sentences": 5,
  "mean_velocity": 0.42,
  "mean_acceleration": 0.15,
  "max_acceleration": 0.68,
  "acceleration_spikes": [
    {"magnitude": 0.68, "isolation_score": 0.85, "position_ratio": 0.6}
  ],
  "deadpan_score": 0.65,
  "heller_score": 0.30,
  "circularity_score": 0.12,
  "tautology_density": 0.05,
  "deceleration_score": 0.22,
  "adams_interpretation": "Moderate deadpan structure β€” isolated semantic spike in stable background",
  "heller_interpretation": "Low circular reasoning"
}
```

| Score | Measures | High = |
|-------|----------|--------|
| `deadpan_score` | Isolated semantic spikes in stable background (Adams-style) | Strong deadpan |
| `heller_score` | Circular, decelerating semantic path (Heller-style) | Circular reasoning |
| `circularity_score` | How close the ending is to the beginning in embedding space | Text returns to start |
| `tautology_density` | Proportion of near-zero velocity segments | Repetitive/redundant |

**LAS use case:** Detect circular reasoning in specialist outputs β€” a model that keeps restating the same idea in different words will score high on `heller_score` and `tautology_density`.

---

### `compare_trajectories(golden_text, synthetic_text, acceleration_threshold)`

Compare trajectory profile of a synthetic (model-generated) text against a golden reference. Returns a fitness score based on DTW alignment and acceleration correlation.

**Requires:** `semantic-chunker` + embedding model + spaCy.

**Parameters:**

| Parameter | Type | Default | Notes |
|-----------|------|---------|-------|
| `golden_text` | `str` | *required* | Reference passage (target structure) |
| `synthetic_text` | `str` | *required* | Model-generated passage to evaluate |
| `acceleration_threshold` | `float` | `0.3` | |

**Returns:**
```json
{
  "fitness_score": 0.35,
  "synthetic_deadpan": 0.45,
  "synthetic_heller": 0.10,
  "acceleration_dtw": 0.20,
  "acceleration_correlation": 0.72,
  "spike_position_match": 0.80,
  "spike_count_match": 0.90,
  "interpretation": "Good structural match with some rhythm deviation",
  "golden_summary": {"n_sentences": 5, "deadpan_score": 0.65, "mean_velocity": 0.42},
  "synthetic_summary": {"n_sentences": 6, "deadpan_score": 0.45, "mean_velocity": 0.38}
}
```

`fitness_score` is 0.0-1.0, lower = better structural match.

---

## Tool Dependencies

```
complete ─────────────────── adapter (LMStudio / Together / HuggingFace)
complete_stream ─────────── adapter
list_models ─────────────── adapter
judge ───────────────────── complete()
generate_variants ───────── complete()
react_step ──────────────── complete_stream()
calculate_drift ─────────── semantic-chunker (embedding model)
analyze_variants ────────── semantic-chunker (embedding model)
analyze_trajectory ──────── semantic-chunker (embedding model + spaCy)
compare_trajectories ────── semantic-chunker (embedding model + spaCy)
```

Tools in the left column work with any registered adapter. Tools in the right column additionally require `semantic-chunker` and a running embedding model. If `semantic-chunker` is unavailable, those tools raise `ImportError` β€” the remaining tools continue to function.

---

## Composition Patterns

### Battery evaluation (ADR-CORE-066, Phase 1)

```python
models = (await list_models())["models"]
for model in models:
    await complete(model, [{"role": "user", "content": "Respond with only 'pong'"}], max_tokens=8)  # warmup
    for test in tests:
        response = await complete(model, test.messages, temperature=0.0)
        verdict = await judge(response, test.pass_criteria, judge_model)
        drift = await calculate_drift(response, test.expected_response)
```

### Facilitator-driven ReAct eval (ADR-CORE-064, Mode 2)

```python
trace, counter = [], 0
while len(trace) < max_iterations:
    result = await react_step(model_id, system_prompt, goal, trace, mock_tools, tools, counter)
    if result["completed"]:
        break
    trace.extend(result["new_iterations"])
    counter = result["call_counter"]
    # Facilitator can apply context curation here before next step
```

### Prompt optimization

```python
variants = await generate_variants("File a bug before writing code", model_id)
distances = await analyze_variants(variants["variants"])
# Test the variant with highest distance β€” it's the one most likely to change model behavior
for label, text in variants["variants"].items():
    response = await complete(model_id, [{"role": "system", "content": text}, {"role": "user", "content": task}])
    verdict = await judge(response, criteria, judge_model)
```

### Circular reasoning detection

```python
trajectory = await analyze_trajectory(specialist_output)
if trajectory["heller_score"] > 0.5 or trajectory["tautology_density"] > 0.3:
    # Model is going in circles β€” flag for review
```