Skip to main content

pointlock_vision/
lib.rs

1//! # pointlock-vision
2//!
3//! The [`VisionVerifier`] plugin interface (verify role only, principle 7:
4//! vision never locates or acts) and the default [`StubVisionVerifier`].
5//!
6//! The verifier is the runtime consumer of the `vision` verify channel
7//! (spine §6.3): it answers an author-written prompt against localized
8//! screenshot bytes with a three-valued verdict. A `pass`/`fail` answer is
9//! a *completed* evaluation (fail is final, spine R5); an `unknown` answer
10//! means the channel could not complete and the chain advances — for the
11//! vision channel, which is only legal at the chain tail, that exhausts the
12//! chain into assertion `unknown` (principle 4).
13//!
14//! The v0.1 default is the stub: it always answers `unknown` with the
15//! reason `"vision verifier not configured"`. The runner treats an absent
16//! verifier (`RunOptions::vision == None`) as exactly equivalent.
17
18use async_trait::async_trait;
19use pointlock_ir::{RectIR, VerdictStatus};
20
21/// The reason the stub (and an unconfigured runner) yields `unknown`.
22pub const STUB_REASON: &str = "vision verifier not configured";
23
24/// One vision verification request: the author-written prompt (never
25/// synthesized by the compiler, principle 6), an optional region of
26/// interest, and the *localized* screenshot bytes (evidence is localized
27/// during `observing`, spine §6.6 — the verifier never reaches back into a
28/// provider session).
29#[derive(Debug, Clone, PartialEq)]
30pub struct VisionRequest<'a> {
31    /// The author-written prompt, verbatim (`AssertionIR.visionPrompt` for
32    /// `elementState`/`elementText` chain tails; `predicate.prompt` for
33    /// `visual` predicates).
34    pub prompt: &'a str,
35    /// Optional region of interest (`visual` predicates only).
36    pub region: Option<&'a RectIR>,
37    /// The localized screenshot bytes.
38    pub screenshot: &'a [u8],
39    /// The screenshot's media type, e.g. `image/png`.
40    pub media_type: &'a str,
41}
42
43/// Identity of a vision judge implementation (evidence honesty: once the
44/// model is swappable, a verdict must say which model answered).
45#[derive(Debug, Clone, PartialEq)]
46pub struct VisionJudge {
47    /// Implementation family, e.g. `anthropic` / `openai-compat`.
48    pub provider: String,
49    /// The requested model id, when the implementation has one.
50    pub model: Option<String>,
51}
52
53/// The verifier's three-valued answer with a human-readable reason.
54///
55/// `pass`/`fail` are completed evaluations; `unknown` means the verifier
56/// could not complete (unconfigured, low confidence, unusable image) and
57/// carries the why. The verdict never panics its way out — model/transport
58/// failures inside an implementation must fold to `unknown` (principle 4).
59#[derive(Debug, Clone, PartialEq)]
60pub struct VisionVerdict {
61    /// The three-valued status.
62    pub status: VerdictStatus,
63    /// Why the verifier answered this way.
64    pub reason: String,
65    /// Which judge answered, when a configured implementation did
66    /// (`None` from the stub — nothing judged).
67    pub judge: Option<VisionJudge>,
68    /// The judge's self-reported on-screen facts (the look-then-judge
69    /// answer protocol), bounded by [`MAX_OBSERVATIONS`] and
70    /// [`MAX_OBSERVATION_CHARS`]; empty when none were reported.
71    pub observations: Vec<String>,
72}
73
74/// The vision verification plugin interface (verify role only).
75#[async_trait]
76pub trait VisionVerifier: Send + Sync {
77    /// Answers `request.prompt` against the screenshot. Infallible by
78    /// construction: anything that prevents an answer is an `unknown`
79    /// verdict with a reason, never an error (principle 4).
80    async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict;
81}
82
83/// The v0.1 default verifier: always `unknown` with [`STUB_REASON`].
84#[derive(Debug, Clone, Copy, Default)]
85pub struct StubVisionVerifier;
86
87#[async_trait]
88impl VisionVerifier for StubVisionVerifier {
89    async fn verify(&self, _request: VisionRequest<'_>) -> VisionVerdict {
90        VisionVerdict {
91            status: VerdictStatus::Unknown,
92            reason: STUB_REASON.to_owned(),
93            judge: None,
94            observations: Vec::new(),
95        }
96    }
97}
98
99// ─── The Anthropic-backed verifier (M3a-W4: the first usable one) ───────────
100
101/// The default model of [`AnthropicVisionVerifier`].
102pub const DEFAULT_VISION_MODEL: &str = "claude-opus-4-8";
103
104/// Total per-request deadline. Without one, a TCP-accepted-but-silent
105/// endpoint stalls `verify()` forever — an unbounded await is a failure
106/// mode that never folds to `unknown`, breaking the module contract
107/// (principle 4). The step's `timeout_ms` governs only provider execute,
108/// not the assert phase, so the bound must live here.
109const REQUEST_TIMEOUT_SECS: u64 = 60;
110
111/// Connection-establishment deadline (part of the same fail-to-unknown
112/// bound; kept tighter so dead endpoints answer fast).
113const CONNECT_TIMEOUT_SECS: u64 = 10;
114
115/// The first usable verifier (08 §6.4): asks an Anthropic vision model to
116/// answer the author's prompt against the screenshot, over raw HTTP (no
117/// official Rust SDK exists). Discipline unchanged from the trait docs:
118/// verify-only, chain tail only, and every failure mode — missing key,
119/// transport, non-200, unparseable answer, model uncertainty — folds to
120/// `unknown` with a reason, never an error and never a guessed pass
121/// (principles 4/7).
122pub struct AnthropicVisionVerifier {
123    api_key: String,
124    model: String,
125    base_url: String,
126    client: reqwest::Client,
127}
128
129impl AnthropicVisionVerifier {
130    /// Builds a verifier from explicit configuration.
131    pub fn new(
132        api_key: impl Into<String>,
133        model: impl Into<String>,
134        base_url: impl Into<String>,
135    ) -> Self {
136        AnthropicVisionVerifier {
137            api_key: api_key.into(),
138            model: model.into(),
139            base_url: normalize_base_url(base_url.into()),
140            client: http_client(),
141        }
142    }
143
144    /// Builds from the environment: `ANTHROPIC_API_KEY` (required — `None`
145    /// without it), `POINTLOCK_VISION_MODEL` (default
146    /// [`DEFAULT_VISION_MODEL`]), `ANTHROPIC_BASE_URL` (default the public
147    /// API; overriding it is also how the tests run against a local
148    /// canned-response server).
149    pub fn from_env() -> Option<Self> {
150        Self::from_lookup(|key| std::env::var(key).ok())
151    }
152
153    /// The injectable body of [`Self::from_env`] (unit-testable without
154    /// process-global env mutation).
155    fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Option<Self> {
156        let api_key = get("ANTHROPIC_API_KEY").filter(|key| !key.is_empty())?;
157        let model = get("POINTLOCK_VISION_MODEL")
158            .filter(|model| !model.is_empty())
159            .unwrap_or_else(|| DEFAULT_VISION_MODEL.to_owned());
160        let base_url = get("ANTHROPIC_BASE_URL")
161            .filter(|url| !url.is_empty())
162            .unwrap_or_else(|| "https://api.anthropic.com".to_owned());
163        Some(Self::new(api_key, model, base_url))
164    }
165
166    fn judge(&self) -> VisionJudge {
167        VisionJudge {
168            provider: "anthropic".to_owned(),
169            model: Some(self.model.clone()),
170        }
171    }
172
173    fn unknown(&self, reason: impl Into<String>) -> VisionVerdict {
174        VisionVerdict {
175            status: VerdictStatus::Unknown,
176            reason: reason.into(),
177            judge: Some(self.judge()),
178            observations: Vec::new(),
179        }
180    }
181}
182
183/// Cap on self-reported observations kept per answer, and per-observation
184/// character bound — both bound ledger growth: observations enter the
185/// durable assertion record verbatim.
186pub const MAX_OBSERVATIONS: usize = 16;
187/// See [`MAX_OBSERVATIONS`].
188pub const MAX_OBSERVATION_CHARS: usize = 300;
189
190/// Character bound on the verdict reason (the judge's free text after
191/// `PASS:`/`FAIL:`/`UNKNOWN:`), which enters the ledger verbatim like the
192/// observations do.
193pub const MAX_REASON_CHARS: usize = MAX_OBSERVATION_CHARS;
194
195/// Cap on a verifier response body: a larger answer is not a verdict and
196/// folds to `unknown` instead of being buffered unbounded.
197const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
198
199/// Trailing slashes are dropped so `{base}/v1/messages` style joins never
200/// produce `//` (which routes to nothing and folds every verify to unknown).
201fn normalize_base_url(base_url: String) -> String {
202    let trimmed = base_url.trim_end_matches('/');
203    if trimmed.len() == base_url.len() {
204        base_url
205    } else {
206        trimmed.to_owned()
207    }
208}
209
210/// Reads a response body under [`MAX_RESPONSE_BYTES`]; `Err` carries the
211/// fold-to-unknown reason.
212async fn bounded_body(mut response: reqwest::Response) -> Result<Vec<u8>, String> {
213    let over = format!("vision response exceeds {MAX_RESPONSE_BYTES} bytes");
214    if response
215        .content_length()
216        .is_some_and(|len| len > MAX_RESPONSE_BYTES as u64)
217    {
218        return Err(over);
219    }
220    let mut body = Vec::new();
221    loop {
222        match response.chunk().await {
223            Ok(Some(chunk)) => {
224                body.extend_from_slice(&chunk);
225                if body.len() > MAX_RESPONSE_BYTES {
226                    return Err(over);
227                }
228            }
229            Ok(None) => return Ok(body),
230            Err(err) => return Err(format!("vision response unreadable: {err}")),
231        }
232    }
233}
234
235/// The shared verification instruction (the look-then-judge answer
236/// protocol): the judge first lists the on-screen facts it bases its
237/// answer on, then gives exactly one verdict line. The observation lines
238/// become checkable evidence next to the verdict; the pinned vocabulary
239/// keeps parsing fail-closed.
240fn verification_instruction(request: &VisionRequest<'_>) -> String {
241    let region_note = request.region.map_or(String::new(), |region| {
242        format!(
243            " Consider ONLY the region at x={}, y={}, width={}, height={} (pixels from the top-left).",
244            region.x, region.y, region.width, region.height
245        )
246    });
247    format!(
248        "You are a visual verification oracle for a device-automation audit trail. \
249         Judge the following claim against the screenshot.{region_note}\n\
250         Claim: {}\n\
251         Answer in EXACTLY this form and nothing else. First, zero or more lines, each:\n\
252         OBSERVED: <one concrete on-screen fact relevant to the claim>\n\
253         Then exactly one final line, one of:\n\
254         PASS: <what you see that confirms it>\n\
255         FAIL: <what you see that contradicts it>\n\
256         UNKNOWN: <why it cannot be determined>\n\
257         Answer UNKNOWN unless the claim is clearly confirmed or clearly contradicted.",
258        request.prompt
259    )
260}
261
262/// The shared HTTP client of the remote verifiers. Deadlines: see the
263/// timeout consts. `no_proxy` keeps egress deterministic (v0.1 makes no
264/// proxy promise) and the canned local-endpoint tests hermetic under
265/// ambient HTTP(S)_PROXY / ALL_PROXY environments. The `expect` matches
266/// `reqwest::Client::new`'s own panic-on-TLS-init semantics.
267fn http_client() -> reqwest::Client {
268    reqwest::Client::builder()
269        .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
270        .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
271        .no_proxy()
272        .build()
273        .expect("reqwest client construction")
274}
275
276/// Parses one answer under the look-then-judge protocol: zero or more
277/// leading `OBSERVED:` fact lines, then exactly one verdict line. A
278/// missing or malformed verdict line parses to `unknown` (fail-closed —
279/// a chatty answer is not a verdict); lines after the verdict are
280/// ignored. Observations are bounded before they can enter any durable
281/// record, and ride along even on a failed parse (they are still what
282/// the judge reported seeing).
283fn parse_answer(text: &str, judge: &VisionJudge) -> VisionVerdict {
284    let mut observations: Vec<String> = Vec::new();
285    let mut verdict_line: Option<&str> = None;
286    for line in text.trim().lines().map(str::trim) {
287        if line.is_empty() {
288            continue;
289        }
290        if let Some(fact) = line.strip_prefix("OBSERVED:") {
291            if observations.len() < MAX_OBSERVATIONS {
292                observations.push(bounded_chars(fact.trim(), MAX_OBSERVATION_CHARS));
293            }
294            continue;
295        }
296        verdict_line = Some(line);
297        break;
298    }
299    let unknown = |reason: String, observations: Vec<String>| VisionVerdict {
300        status: VerdictStatus::Unknown,
301        reason,
302        judge: Some(judge.clone()),
303        observations,
304    };
305    let Some(first) = verdict_line else {
306        return unknown(
307            "the verifier answer carried no verdict line".to_owned(),
308            observations,
309        );
310    };
311    let (status, rest) = if let Some(rest) = first.strip_prefix("PASS:") {
312        (VerdictStatus::Pass, rest)
313    } else if let Some(rest) = first.strip_prefix("FAIL:") {
314        (VerdictStatus::Fail, rest)
315    } else if let Some(rest) = first.strip_prefix("UNKNOWN:") {
316        (VerdictStatus::Unknown, rest)
317    } else {
318        return unknown(
319            format!("unparseable verifier answer: {first:.120}"),
320            observations,
321        );
322    };
323    VisionVerdict {
324        status,
325        reason: format!("vision: {}", bounded_chars(rest.trim(), MAX_REASON_CHARS)),
326        judge: Some(judge.clone()),
327        observations,
328    }
329}
330
331/// Truncates to a character bound on a char boundary, marking the cut.
332fn bounded_chars(value: &str, max_chars: usize) -> String {
333    if value.chars().count() <= max_chars {
334        return value.to_owned();
335    }
336    let mut bounded: String = value.chars().take(max_chars).collect();
337    bounded.push('…');
338    bounded
339}
340
341#[async_trait]
342impl VisionVerifier for AnthropicVisionVerifier {
343    async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict {
344        use base64::Engine as _;
345        let data = base64::engine::general_purpose::STANDARD.encode(request.screenshot);
346        let instruction = verification_instruction(&request);
347        let body = serde_json::json!({
348            "model": self.model,
349            // Room for the observation lines ahead of the verdict line.
350            "max_tokens": 2048,
351            "messages": [{
352                "role": "user",
353                "content": [
354                    { "type": "image", "source": {
355                        "type": "base64",
356                        "media_type": request.media_type,
357                        "data": data,
358                    }},
359                    { "type": "text", "text": instruction },
360                ],
361            }],
362        });
363
364        let response = match self
365            .client
366            .post(format!("{}/v1/messages", self.base_url))
367            .header("x-api-key", &self.api_key)
368            .header("anthropic-version", "2023-06-01")
369            .json(&body)
370            .send()
371            .await
372        {
373            Ok(response) => response,
374            Err(err) => return self.unknown(format!("vision transport failed: {err}")),
375        };
376        if !response.status().is_success() {
377            let status = response.status();
378            let body = bounded_body(response).await.unwrap_or_default();
379            return self.unknown(format!(
380                "vision API answered {status}: {:.200}",
381                String::from_utf8_lossy(&body).trim()
382            ));
383        }
384        let body = match bounded_body(response).await {
385            Ok(body) => body,
386            Err(reason) => return self.unknown(reason),
387        };
388        let parsed: serde_json::Value = match serde_json::from_slice(&body) {
389            Ok(parsed) => parsed,
390            Err(err) => return self.unknown(format!("vision response unreadable: {err}")),
391        };
392        // Concatenate the text blocks (thinking blocks are skipped).
393        let text: String = parsed
394            .get("content")
395            .and_then(|content| content.as_array())
396            .map(|blocks| {
397                blocks
398                    .iter()
399                    .filter(|block| block.get("type").and_then(|t| t.as_str()) == Some("text"))
400                    .filter_map(|block| block.get("text").and_then(|t| t.as_str()))
401                    .collect::<Vec<_>>()
402                    .join("")
403            })
404            .unwrap_or_default();
405        if text.trim().is_empty() {
406            return self.unknown("vision response carried no text answer");
407        }
408        parse_answer(&text, &self.judge())
409    }
410}
411
412// ─── The OpenAI-compatible verifier (graphics-focused open models) ──────────
413
414/// A verifier over the OpenAI-compatible chat-completions wire format —
415/// how graphics-focused open models (Qwen-VL, GLM-4V, …) are typically
416/// served, both hosted and self-hosted (vLLM). Same discipline as the
417/// Anthropic verifier: verify-only, chain tail only, every failure mode
418/// folds to `unknown` with a reason (principles 4/7), and the same
419/// look-then-judge answer protocol so verdicts stay comparable across
420/// providers.
421pub struct OpenAiCompatVisionVerifier {
422    /// Bearer token; absent for endpoints that need none (local vLLM).
423    api_key: Option<String>,
424    model: String,
425    /// Endpoint base *including* the ecosystem-conventional `/v1`
426    /// (e.g. `http://127.0.0.1:8000/v1`); `/chat/completions` is appended.
427    base_url: String,
428    client: reqwest::Client,
429}
430
431impl OpenAiCompatVisionVerifier {
432    /// Builds a verifier from explicit configuration.
433    pub fn new(
434        api_key: Option<String>,
435        model: impl Into<String>,
436        base_url: impl Into<String>,
437    ) -> Self {
438        OpenAiCompatVisionVerifier {
439            api_key,
440            model: model.into(),
441            base_url: normalize_base_url(base_url.into()),
442            client: http_client(),
443        }
444    }
445
446    /// Builds from the environment: `POINTLOCK_VISION_BASE_URL` and
447    /// `POINTLOCK_VISION_MODEL` are both required (`None` without them —
448    /// there is no canonical public endpoint or model to default to);
449    /// `POINTLOCK_VISION_API_KEY` is optional (self-hosted endpoints
450    /// commonly need none).
451    pub fn from_env() -> Option<Self> {
452        Self::from_lookup(|key| std::env::var(key).ok())
453    }
454
455    /// The injectable body of [`Self::from_env`] (unit-testable without
456    /// process-global env mutation).
457    fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Option<Self> {
458        let base_url = get("POINTLOCK_VISION_BASE_URL").filter(|url| !url.is_empty())?;
459        let model = get("POINTLOCK_VISION_MODEL").filter(|model| !model.is_empty())?;
460        let api_key = get("POINTLOCK_VISION_API_KEY").filter(|key| !key.is_empty());
461        Some(Self::new(api_key, model, base_url))
462    }
463
464    fn judge(&self) -> VisionJudge {
465        VisionJudge {
466            provider: "openai-compat".to_owned(),
467            model: Some(self.model.clone()),
468        }
469    }
470
471    fn unknown(&self, reason: impl Into<String>) -> VisionVerdict {
472        VisionVerdict {
473            status: VerdictStatus::Unknown,
474            reason: reason.into(),
475            judge: Some(self.judge()),
476            observations: Vec::new(),
477        }
478    }
479}
480
481#[async_trait]
482impl VisionVerifier for OpenAiCompatVisionVerifier {
483    async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict {
484        use base64::Engine as _;
485        let data = base64::engine::general_purpose::STANDARD.encode(request.screenshot);
486        let instruction = verification_instruction(&request);
487        let body = serde_json::json!({
488            "model": self.model,
489            // Room for the observation lines ahead of the verdict line.
490            "max_tokens": 2048,
491            "messages": [{
492                "role": "user",
493                "content": [
494                    { "type": "image_url", "image_url": {
495                        "url": format!("data:{};base64,{data}", request.media_type),
496                    }},
497                    { "type": "text", "text": instruction },
498                ],
499            }],
500        });
501
502        let mut post = self
503            .client
504            .post(format!("{}/chat/completions", self.base_url))
505            .json(&body);
506        if let Some(key) = &self.api_key {
507            post = post.bearer_auth(key);
508        }
509        let response = match post.send().await {
510            Ok(response) => response,
511            Err(err) => return self.unknown(format!("vision transport failed: {err}")),
512        };
513        if !response.status().is_success() {
514            let status = response.status();
515            let body = bounded_body(response).await.unwrap_or_default();
516            return self.unknown(format!(
517                "vision API answered {status}: {:.200}",
518                String::from_utf8_lossy(&body).trim()
519            ));
520        }
521        let body = match bounded_body(response).await {
522            Ok(body) => body,
523            Err(reason) => return self.unknown(reason),
524        };
525        let parsed: serde_json::Value = match serde_json::from_slice(&body) {
526            Ok(parsed) => parsed,
527            Err(err) => return self.unknown(format!("vision response unreadable: {err}")),
528        };
529        // `choices[0].message.content` is a string on the chat-completions
530        // format; some servers answer an array of typed parts instead —
531        // accept both, concatenating the text parts.
532        let content = &parsed["choices"][0]["message"]["content"];
533        let text: String = match content {
534            serde_json::Value::String(text) => text.clone(),
535            serde_json::Value::Array(parts) => parts
536                .iter()
537                .filter(|part| part.get("type").and_then(|t| t.as_str()) == Some("text"))
538                .filter_map(|part| part.get("text").and_then(|t| t.as_str()))
539                .collect::<Vec<_>>()
540                .join(""),
541            _ => String::new(),
542        };
543        if text.trim().is_empty() {
544            return self.unknown("vision response carried no text answer");
545        }
546        parse_answer(&text, &self.judge())
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn from_lookup_requires_a_nonempty_api_key() {
556        assert!(AnthropicVisionVerifier::from_lookup(|_| None).is_none());
557        assert!(
558            AnthropicVisionVerifier::from_lookup(|key| {
559                (key == "ANTHROPIC_API_KEY").then(String::new)
560            })
561            .is_none()
562        );
563    }
564
565    #[test]
566    fn from_lookup_honors_the_model_override_and_defaults_without_it() {
567        let with_override = AnthropicVisionVerifier::from_lookup(|key| match key {
568            "ANTHROPIC_API_KEY" => Some("k".to_owned()),
569            "POINTLOCK_VISION_MODEL" => Some("claude-haiku-4-5".to_owned()),
570            _ => None,
571        })
572        .expect("key present");
573        assert_eq!(with_override.model, "claude-haiku-4-5");
574
575        let defaulted = AnthropicVisionVerifier::from_lookup(|key| {
576            (key == "ANTHROPIC_API_KEY").then(|| "k".to_owned())
577        })
578        .expect("key present");
579        assert_eq!(defaulted.model, DEFAULT_VISION_MODEL);
580        assert_eq!(defaulted.base_url, "https://api.anthropic.com");
581    }
582
583    #[tokio::test]
584    async fn stub_always_answers_unknown_with_the_fixed_reason() {
585        let verdict = StubVisionVerifier
586            .verify(VisionRequest {
587                prompt: "the Wi-Fi toggle is visible",
588                region: None,
589                screenshot: b"png-bytes",
590                media_type: "image/png",
591            })
592            .await;
593        assert_eq!(verdict.status, VerdictStatus::Unknown);
594        assert_eq!(verdict.reason, STUB_REASON);
595        // Nothing judged: no judge identity, no observations.
596        assert_eq!(verdict.judge, None);
597        assert!(verdict.observations.is_empty());
598    }
599
600    fn judge() -> VisionJudge {
601        VisionJudge {
602            provider: "test".to_owned(),
603            model: Some("test-model".to_owned()),
604        }
605    }
606
607    #[test]
608    fn parse_collects_observations_ahead_of_the_verdict() {
609        let verdict = parse_answer(
610            "OBSERVED: the SSID field shows HomeWifi\n\
611             OBSERVED: the connect button is enabled\n\
612             PASS: the field shows the requested name",
613            &judge(),
614        );
615        assert_eq!(verdict.status, VerdictStatus::Pass);
616        assert_eq!(verdict.reason, "vision: the field shows the requested name");
617        assert_eq!(verdict.judge, Some(judge()));
618        assert_eq!(
619            verdict.observations,
620            vec![
621                "the SSID field shows HomeWifi".to_owned(),
622                "the connect button is enabled".to_owned(),
623            ]
624        );
625    }
626
627    #[test]
628    fn parse_accepts_a_bare_verdict_without_observed_lines() {
629        let verdict = parse_answer("FAIL: the field is empty", &judge());
630        assert_eq!(verdict.status, VerdictStatus::Fail);
631        assert_eq!(verdict.judge, Some(judge()));
632        assert!(verdict.observations.is_empty());
633    }
634
635    #[test]
636    fn parse_fails_closed_but_keeps_observations_without_a_verdict() {
637        let missing = parse_answer("OBSERVED: a dialog covers the screen", &judge());
638        assert_eq!(missing.status, VerdictStatus::Unknown);
639        assert!(
640            missing.reason.contains("no verdict line"),
641            "{}",
642            missing.reason
643        );
644        assert_eq!(
645            missing.observations,
646            vec!["a dialog covers the screen".to_owned()]
647        );
648
649        let chatty = parse_answer(
650            "OBSERVED: a dialog covers the screen\nSure! I believe it passes.",
651            &judge(),
652        );
653        assert_eq!(chatty.status, VerdictStatus::Unknown);
654        assert!(chatty.reason.contains("unparseable"), "{}", chatty.reason);
655        assert_eq!(
656            chatty.observations,
657            vec!["a dialog covers the screen".to_owned()]
658        );
659    }
660
661    #[test]
662    fn observation_bounds_cap_count_and_length_on_char_boundaries() {
663        let mut answer = String::new();
664        for index in 0..(MAX_OBSERVATIONS + 3) {
665            answer.push_str(&format!("OBSERVED: fact {index}\n"));
666        }
667        answer.push_str("PASS: ok");
668        let verdict = parse_answer(&answer, &judge());
669        assert_eq!(verdict.observations.len(), MAX_OBSERVATIONS);
670
671        // Multi-byte characters must be cut on a char boundary.
672        let long = format!(
673            "OBSERVED: {}\nPASS: ok",
674            "界".repeat(MAX_OBSERVATION_CHARS + 5)
675        );
676        let verdict = parse_answer(&long, &judge());
677        let kept = &verdict.observations[0];
678        assert_eq!(kept.chars().count(), MAX_OBSERVATION_CHARS + 1);
679        assert!(kept.ends_with('…'));
680    }
681
682    #[test]
683    fn reason_is_bounded_like_observations() {
684        let long = format!("PASS: {}", "界".repeat(MAX_REASON_CHARS + 5));
685        let verdict = parse_answer(&long, &judge());
686        assert_eq!(verdict.status, VerdictStatus::Pass);
687        let reason = verdict.reason.strip_prefix("vision: ").expect("prefix");
688        assert_eq!(reason.chars().count(), MAX_REASON_CHARS + 1);
689        assert!(reason.ends_with('…'));
690    }
691
692    #[test]
693    fn trailing_slashes_are_trimmed_from_base_urls() {
694        let anthropic = AnthropicVisionVerifier::new("k", "m", "https://api.anthropic.com/");
695        assert_eq!(anthropic.base_url, "https://api.anthropic.com");
696        let anthropic = AnthropicVisionVerifier::from_lookup(|key| match key {
697            "ANTHROPIC_API_KEY" => Some("k".to_owned()),
698            "ANTHROPIC_BASE_URL" => Some("http://127.0.0.1:1//".to_owned()),
699            _ => None,
700        })
701        .expect("key present");
702        assert_eq!(anthropic.base_url, "http://127.0.0.1:1");
703        let openai = OpenAiCompatVisionVerifier::new(None, "m", "http://127.0.0.1:8000/v1/");
704        assert_eq!(openai.base_url, "http://127.0.0.1:8000/v1");
705    }
706
707    #[test]
708    fn openai_from_lookup_requires_base_url_and_model_with_the_key_optional() {
709        assert!(OpenAiCompatVisionVerifier::from_lookup(|_| None).is_none());
710        assert!(
711            OpenAiCompatVisionVerifier::from_lookup(|key| {
712                (key == "POINTLOCK_VISION_BASE_URL").then(|| "http://127.0.0.1:1/v1".to_owned())
713            })
714            .is_none()
715        );
716        let keyless = OpenAiCompatVisionVerifier::from_lookup(|key| match key {
717            "POINTLOCK_VISION_BASE_URL" => Some("http://127.0.0.1:1/v1".to_owned()),
718            "POINTLOCK_VISION_MODEL" => Some("qwen2.5-vl".to_owned()),
719            _ => None,
720        })
721        .expect("base url and model present");
722        assert_eq!(keyless.api_key, None);
723        assert_eq!(keyless.model, "qwen2.5-vl");
724        let keyed = OpenAiCompatVisionVerifier::from_lookup(|key| match key {
725            "POINTLOCK_VISION_BASE_URL" => Some("http://127.0.0.1:1/v1".to_owned()),
726            "POINTLOCK_VISION_MODEL" => Some("qwen2.5-vl".to_owned()),
727            "POINTLOCK_VISION_API_KEY" => Some("k".to_owned()),
728            _ => None,
729        })
730        .expect("all present");
731        assert_eq!(keyed.api_key.as_deref(), Some("k"));
732    }
733}