Skip to main content

pixelcoords_core/
verdict.rs

1//! Point-in-region verdicts against a saved session — the logic behind
2//! `pixelcoords assert`.
3//!
4//! A verdict answers "does this point land inside a marked region?" for a
5//! point expressed in any of the session's coordinate spaces. It exists so
6//! automation — a computer-use agent, a click script under test — can be
7//! scored against regions a human marked once, without reimplementing the
8//! session's geometry.
9
10use serde::Serialize;
11use thiserror::Error;
12
13use crate::geometry::{Point, Rect, Shape, ToolKind};
14use crate::session::{SelectionRecord, SessionFile};
15use crate::space::Origin;
16
17#[derive(Debug, Error, PartialEq, Eq)]
18pub enum VerdictError {
19    #[error(
20        "the session has no target window — window-relative points need a \
21         session captured with --target"
22    )]
23    NoTarget,
24    #[error("monitor {requested} is not in this session; it has monitors {available:?}")]
25    UnknownMonitor {
26        requested: usize,
27        available: Vec<usize>,
28    },
29    #[error("no selection is labeled {requested:?}; labels in this space: {available:?}")]
30    UnknownLabel {
31        requested: String,
32        available: Vec<String>,
33    },
34    #[error("the session has no selections in {0} space to test against")]
35    NoCandidates(&'static str),
36}
37
38/// The result of testing one point: one row of `assert`'s output.
39///
40/// Carries no `schema` of its own — it is a row inside
41/// [`crate::report::Report`], which versions the document. `hit` stays
42/// here rather than rising to the envelope's `ok`, because a batch run
43/// answers per line and a caller scoring a trajectory needs to know
44/// *which* click missed.
45#[derive(Debug, Clone, PartialEq, Serialize)]
46pub struct Verdict {
47    /// 1-based line of the input stream this point came from; absent when
48    /// a single `--point` was scored, so the two shapes stay
49    /// field-compatible in the direction that matters — a batch adds a
50    /// field, it never removes one.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub line: Option<usize>,
53    pub point: Point,
54    pub space: &'static str,
55    /// The monitor index the point is local to; present only in monitor
56    /// space.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub monitor: Option<usize>,
59    pub hit: bool,
60    /// Every region containing the point, in session (stacking) order —
61    /// last is topmost. A miss against `--expect` still lists what the
62    /// point *did* land in.
63    pub contained_in: Vec<RegionRef>,
64    /// Present on a miss: the closest relevant region, for partial credit.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub nearest: Option<Nearest>,
67}
68
69impl Verdict {
70    /// Tag this verdict with the 1-based input line it came from.
71    #[must_use]
72    pub fn at_line(mut self, line: usize) -> Self {
73        self.line = Some(line);
74        self
75    }
76}
77
78/// A selection referenced by its position in the session file.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80pub struct RegionRef {
81    pub index: usize,
82    pub label: String,
83    pub shape: ToolKind,
84    pub monitor: usize,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize)]
88pub struct Nearest {
89    pub region: RegionRef,
90    /// Distance in pixels from the point to the region's rotated bounding
91    /// box. 0 means inside the bbox but outside the shape itself.
92    pub bbox_distance_px: f64,
93}
94
95/// One selection viewed through the requested coordinate space.
96struct Candidate<'a> {
97    index: usize,
98    record: &'a SelectionRecord,
99    shape: Shape,
100}
101
102impl Candidate<'_> {
103    fn rot_deg(&self) -> i32 {
104        self.record.rot_deg.unwrap_or(0)
105    }
106
107    fn region_ref(&self) -> RegionRef {
108        RegionRef {
109            index: self.index,
110            label: self.record.label.clone(),
111            shape: self.record.shape,
112            monitor: self.record.monitor,
113        }
114    }
115}
116
117/// Test `point` against the session's regions.
118///
119/// `expect` names the label that has to be hit for `hit` to be true — the
120/// `--expect` flag, not `--label`. It deliberately does *not* filter what
121/// gets reported: `contained_in` still lists every region the point landed
122/// in, so a miss tells you what you hit instead of merely that you missed.
123/// Matching is ASCII case-insensitive, as the window matcher is.
124pub fn assess(
125    session: &SessionFile,
126    point: Point,
127    space: Origin,
128    expect: Option<&str>,
129) -> Result<Verdict, VerdictError> {
130    let candidates = candidates(session, space)?;
131    if candidates.is_empty() {
132        return Err(VerdictError::NoCandidates(space.label()));
133    }
134    if let Some(wanted) = expect {
135        let known = candidates
136            .iter()
137            .any(|c| c.record.label.eq_ignore_ascii_case(wanted));
138        if !known {
139            return Err(VerdictError::UnknownLabel {
140                requested: wanted.to_string(),
141                available: candidate_labels(&candidates),
142            });
143        }
144    }
145
146    let contained: Vec<&Candidate> = candidates
147        .iter()
148        .filter(|c| c.shape.hit_test_rotated(c.rot_deg(), point))
149        .collect();
150    let hit = expect.map_or(!contained.is_empty(), |wanted| {
151        contained
152            .iter()
153            .any(|c| c.record.label.eq_ignore_ascii_case(wanted))
154    });
155    let nearest = if hit {
156        None
157    } else {
158        nearest_relevant(&candidates, point, expect)
159    };
160    Ok(Verdict {
161        line: None,
162        point,
163        space: space.label(),
164        monitor: match space {
165            Origin::Monitor(index) => Some(index),
166            _ => None,
167        },
168        hit,
169        contained_in: contained.iter().map(|c| c.region_ref()).collect(),
170        nearest,
171    })
172}
173
174/// The selections testable in `space`, each carrying the shape already
175/// stored for that space.
176fn candidates(session: &SessionFile, space: Origin) -> Result<Vec<Candidate<'_>>, VerdictError> {
177    let records = session.selections.iter().enumerate();
178    match space {
179        Origin::Global => Ok(records
180            .map(|(index, record)| Candidate {
181                index,
182                record,
183                shape: record.global_px.clone(),
184            })
185            .collect()),
186        Origin::Monitor(wanted) => {
187            let available: Vec<usize> = session.monitors.iter().map(|m| m.index).collect();
188            if !available.contains(&wanted) {
189                return Err(VerdictError::UnknownMonitor {
190                    requested: wanted,
191                    available,
192                });
193            }
194            Ok(records
195                .filter(|(_, record)| record.monitor == wanted)
196                .map(|(index, record)| Candidate {
197                    index,
198                    record,
199                    shape: record.px.clone(),
200                })
201                .collect())
202        }
203        Origin::Window => {
204            if session.target.is_none() {
205                return Err(VerdictError::NoTarget);
206            }
207            Ok(records
208                .filter_map(|(index, record)| {
209                    record.window_px.clone().map(|shape| Candidate {
210                        index,
211                        record,
212                        shape,
213                    })
214                })
215                .collect())
216        }
217    }
218}
219
220/// The labels `--expect` could have named: those among the candidates
221/// this space admits, not the whole session — a monitor-space question
222/// cannot be answered by a selection on another monitor.
223fn candidate_labels(candidates: &[Candidate]) -> Vec<String> {
224    crate::session::distinct_labels(candidates.iter().map(|c| c.record))
225}
226
227/// The closest region the miss was measured against: the labeled ones when
228/// a label was requested, otherwise all of them.
229fn nearest_relevant(
230    candidates: &[Candidate],
231    point: Point,
232    expect: Option<&str>,
233) -> Option<Nearest> {
234    candidates
235        .iter()
236        .filter(|c| expect.is_none_or(|wanted| c.record.label.eq_ignore_ascii_case(wanted)))
237        .map(|c| Nearest {
238            region: c.region_ref(),
239            bbox_distance_px: bbox_distance(c.shape.rotated_bbox(c.rot_deg()), point),
240        })
241        .min_by(|a, b| a.bbox_distance_px.total_cmp(&b.bbox_distance_px))
242}
243
244/// Euclidean distance from `p` to the nearest pixel of `rect`, 0 inside.
245/// i64/f64 throughout so extreme deserialized coordinates cannot overflow.
246fn bbox_distance(rect: Rect, p: Point) -> f64 {
247    let dx = axis_distance(p.x, rect.x, rect.w);
248    let dy = axis_distance(p.y, rect.y, rect.h);
249    f64::hypot(dx as f64, dy as f64)
250}
251
252/// Distance from `v` to the half-open interval `[start, start + len)` on
253/// one axis, 0 inside — the same inclusion rule as `Rect::contains`.
254fn axis_distance(v: i32, start: i32, len: i32) -> i64 {
255    let v = i64::from(v);
256    let start = i64::from(start);
257    let end = start + i64::from(len) - 1;
258    if v < start {
259        return start - v;
260    }
261    if v > end {
262        return v - end;
263    }
264    0
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::geometry::{Rect, Size};
271    use crate::selection::Selection;
272    use crate::session::{MonitorRecord, TargetRecord};
273
274    fn monitor(index: usize, ox: i32, oy: i32) -> MonitorRecord {
275        MonitorRecord {
276            index,
277            name: format!("Display {index}"),
278            primary: index == 0,
279            origin_px: Point::new(ox, oy),
280            size_px: Size::new(1920, 1080),
281            scale: 2.0,
282        }
283    }
284
285    fn labeled(shape: Shape, monitor: usize, label: &str) -> Selection {
286        let mut sel = Selection::new(shape, monitor);
287        sel.label = label.into();
288        sel
289    }
290
291    fn session(selections: &[Selection], target: Option<TargetRecord>) -> SessionFile {
292        let crops: Vec<String> = (0..selections.len()).map(|i| format!("c{i}.png")).collect();
293        SessionFile::build(
294            "test",
295            "2026-07-27T00:00:00Z".into(),
296            vec![monitor(0, 0, 0), monitor(1, 1920, 0)],
297            selections,
298            &crops,
299            target,
300        )
301    }
302
303    #[test]
304    fn global_space_hits_through_the_monitor_origin() {
305        // Local (10, 20) on monitor 1 sits at global (1930, 20).
306        let file = session(
307            &[labeled(Shape::Rect(Rect::new(10, 20, 30, 40)), 1, "submit")],
308            None,
309        );
310        let hit = assess(&file, Point::new(1935, 25), Origin::Global, None).unwrap();
311        assert!(hit.hit);
312        assert_eq!(hit.contained_in[0].label, "submit");
313        assert!(hit.nearest.is_none());
314        let miss = assess(&file, Point::new(15, 25), Origin::Global, None).unwrap();
315        assert!(!miss.hit);
316    }
317
318    #[test]
319    fn monitor_space_tests_local_pixels_of_that_monitor_only() {
320        let file = session(
321            &[
322                labeled(Shape::Rect(Rect::new(10, 20, 30, 40)), 1, "right"),
323                labeled(Shape::Rect(Rect::new(10, 20, 30, 40)), 0, "left"),
324            ],
325            None,
326        );
327        let v = assess(&file, Point::new(15, 25), Origin::Monitor(1), None).unwrap();
328        assert!(v.hit);
329        assert_eq!(v.monitor, Some(1));
330        // Both monitors hold an identical local rect; only monitor 1's is
331        // eligible, so exactly one region contains the point.
332        assert_eq!(v.contained_in.len(), 1);
333        assert_eq!(v.contained_in[0].label, "right");
334    }
335
336    #[test]
337    fn unknown_monitor_is_an_error_naming_the_real_ones() {
338        let file = session(&[labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "a")], None);
339        let err = assess(&file, Point::new(0, 0), Origin::Monitor(7), None).unwrap_err();
340        assert_eq!(
341            err,
342            VerdictError::UnknownMonitor {
343                requested: 7,
344                available: vec![0, 1],
345            }
346        );
347    }
348
349    #[test]
350    fn monitor_with_no_selections_is_no_candidates_not_a_miss() {
351        let file = session(&[labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "a")], None);
352        let err = assess(&file, Point::new(2, 2), Origin::Monitor(1), None).unwrap_err();
353        assert_eq!(err, VerdictError::NoCandidates("monitor"));
354    }
355
356    #[test]
357    fn window_space_needs_a_target_session() {
358        let file = session(&[labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "a")], None);
359        let err = assess(&file, Point::new(2, 2), Origin::Window, None).unwrap_err();
360        assert_eq!(err, VerdictError::NoTarget);
361    }
362
363    #[test]
364    fn window_space_uses_window_relative_coords_and_skips_other_monitors() {
365        let target = TargetRecord {
366            app: "Editor".into(),
367            title: "main.rs".into(),
368            monitor: 0,
369            origin_px: Point::new(400, 250),
370            size_px: Size::new(800, 600),
371        };
372        let file = session(
373            &[
374                labeled(Shape::Rect(Rect::new(500, 300, 40, 20)), 0, "on target"),
375                labeled(Shape::Rect(Rect::new(1, 1, 5, 5)), 1, "elsewhere"),
376            ],
377            Some(target),
378        );
379        // Window-relative: the rect sits at (100, 50) from the window origin.
380        let v = assess(&file, Point::new(110, 55), Origin::Window, None).unwrap();
381        assert!(v.hit);
382        assert_eq!(v.contained_in.len(), 1);
383        assert_eq!(v.contained_in[0].label, "on target");
384    }
385
386    #[test]
387    fn label_filter_is_case_insensitive_and_sees_through_overlap() {
388        let file = session(
389            &[
390                labeled(Shape::Rect(Rect::new(0, 0, 100, 100)), 0, "Cancel"),
391                labeled(Shape::Rect(Rect::new(200, 0, 50, 50)), 0, "Submit"),
392            ],
393            None,
394        );
395        let v = assess(&file, Point::new(10, 10), Origin::Global, Some("SUBMIT")).unwrap();
396        // The point is inside "Cancel", so the verdict is a labeled miss
397        // that still reports what was actually hit — and how far the
398        // wanted region is.
399        assert!(!v.hit);
400        assert_eq!(v.contained_in.len(), 1);
401        assert_eq!(v.contained_in[0].label, "Cancel");
402        let nearest = v.nearest.unwrap();
403        assert_eq!(nearest.region.label, "Submit");
404        assert!(nearest.bbox_distance_px > 0.0);
405
406        let hit = assess(&file, Point::new(210, 10), Origin::Global, Some("submit")).unwrap();
407        assert!(hit.hit);
408    }
409
410    #[test]
411    fn unknown_label_is_an_error_listing_the_real_ones() {
412        let file = session(
413            &[
414                labeled(Shape::Rect(Rect::new(0, 0, 5, 5)), 0, "Submit"),
415                labeled(Shape::Rect(Rect::new(9, 9, 5, 5)), 0, "submit"),
416                labeled(Shape::Rect(Rect::new(20, 20, 5, 5)), 0, ""),
417            ],
418            None,
419        );
420        let err = assess(&file, Point::new(0, 0), Origin::Global, Some("send")).unwrap_err();
421        // Case-insensitive duplicates collapse; the unlabeled one is not
422        // offered.
423        assert_eq!(
424            err,
425            VerdictError::UnknownLabel {
426                requested: "send".into(),
427                available: vec!["Submit".into()],
428            }
429        );
430    }
431
432    #[test]
433    fn empty_session_is_no_candidates() {
434        let file = session(&[], None);
435        let err = assess(&file, Point::new(0, 0), Origin::Global, None).unwrap_err();
436        assert_eq!(err, VerdictError::NoCandidates("global"));
437    }
438
439    #[test]
440    fn rotated_rect_is_tested_as_the_user_saw_it() {
441        // A wide rect rotated 90° stands tall: x 25..35, y -5..35 visually.
442        let mut sel = Selection::new(Shape::Rect(Rect::new(10, 10, 40, 10)), 0);
443        sel.rot_deg = 90;
444        let file = session(&[sel], None);
445        let inside_rotated = Point::new(28, 27);
446        let v = assess(&file, inside_rotated, Origin::Global, None).unwrap();
447        assert!(v.hit, "point inside the rotated silhouette must hit");
448        let inside_unrotated_only = Point::new(45, 15);
449        let v = assess(&file, inside_unrotated_only, Origin::Global, None).unwrap();
450        assert!(
451            !v.hit,
452            "the axis-aligned box no longer applies once rotated"
453        );
454    }
455
456    #[test]
457    fn circle_and_triangle_hits_use_their_own_geometry() {
458        let file = session(
459            &[
460                labeled(
461                    Shape::Circle {
462                        cx: 50,
463                        cy: 50,
464                        r: 10,
465                    },
466                    0,
467                    "dot",
468                ),
469                labeled(
470                    Shape::Triangle {
471                        ax: 200,
472                        ay: 100,
473                        bx: 150,
474                        by: 200,
475                        cx: 250,
476                        cy: 200,
477                    },
478                    0,
479                    "tri",
480                ),
481            ],
482            None,
483        );
484        // On the circle's rim (inclusive), inside the triangle, and in each
485        // shape's bbox corner — which its geometry excludes.
486        assert!(
487            assess(&file, Point::new(60, 50), Origin::Global, None)
488                .unwrap()
489                .hit
490        );
491        assert!(
492            assess(&file, Point::new(200, 150), Origin::Global, None)
493                .unwrap()
494                .hit
495        );
496        let circle_corner = assess(&file, Point::new(41, 41), Origin::Global, None).unwrap();
497        assert!(!circle_corner.hit);
498        let tri_corner = assess(&file, Point::new(151, 101), Origin::Global, None).unwrap();
499        assert!(!tri_corner.hit);
500    }
501
502    #[test]
503    fn overlapping_hits_come_back_in_stacking_order() {
504        let file = session(
505            &[
506                labeled(Shape::Rect(Rect::new(0, 0, 100, 100)), 0, "below"),
507                labeled(Shape::Rect(Rect::new(0, 0, 50, 50)), 0, "above"),
508            ],
509            None,
510        );
511        let v = assess(&file, Point::new(10, 10), Origin::Global, None).unwrap();
512        let labels: Vec<&str> = v.contained_in.iter().map(|r| r.label.as_str()).collect();
513        assert_eq!(labels, ["below", "above"]);
514        assert_eq!(v.contained_in[1].index, 1);
515    }
516
517    #[test]
518    fn miss_distance_is_euclidean_to_the_bbox() {
519        let file = session(
520            &[labeled(Shape::Rect(Rect::new(10, 10, 20, 20)), 0, "box")],
521            None,
522        );
523        // 3 left of x=10, 4 above y=10: a 3-4-5 triangle.
524        let v = assess(&file, Point::new(7, 6), Origin::Global, None).unwrap();
525        assert!(!v.hit);
526        let nearest = v.nearest.unwrap();
527        assert_eq!(nearest.region.label, "box");
528        assert!((nearest.bbox_distance_px - 5.0).abs() < f64::EPSILON);
529    }
530
531    #[test]
532    fn miss_distance_uses_the_rotated_bbox() {
533        // Rotated 90°, the wide rect's silhouette spans x 25..35 — a point
534        // just right of it measures against that tall box, not the
535        // original wide one.
536        let mut sel = Selection::new(Shape::Rect(Rect::new(10, 10, 40, 10)), 0);
537        sel.rot_deg = 90;
538        let file = session(&[sel], None);
539        let v = assess(&file, Point::new(40, 0), Origin::Global, None).unwrap();
540        assert!(!v.hit);
541        // The rotated bbox reaches x=35 at y=0; distance is 40-34=6 in x
542        // per half-open inclusion, 0 in y — well under the unrotated
543        // bbox's answer (which would include a 10px y gap).
544        let d = v.nearest.unwrap().bbox_distance_px;
545        assert!(
546            d < 8.0,
547            "distance {d} should measure the rotated silhouette"
548        );
549    }
550
551    #[test]
552    fn a_line_number_is_added_without_disturbing_the_answer() {
553        let file = session(
554            &[labeled(Shape::Rect(Rect::new(10, 10, 20, 20)), 0, "box")],
555            None,
556        );
557        let bare = assess(&file, Point::new(15, 15), Origin::Global, None).unwrap();
558        assert_eq!(bare.line, None, "a single point has no line to report");
559
560        let tagged = bare.clone().at_line(7);
561        assert_eq!(tagged.line, Some(7));
562        assert_eq!(
563            Verdict {
564                line: None,
565                ..tagged
566            },
567            bare,
568            "tagging changes the line and nothing else"
569        );
570    }
571
572    #[test]
573    fn verdict_json_shape_is_stable() {
574        let file = session(
575            &[labeled(Shape::Rect(Rect::new(10, 10, 20, 20)), 0, "box")],
576            None,
577        );
578        let hit = assess(&file, Point::new(15, 15), Origin::Global, None).unwrap();
579        let json = serde_json::to_value(&hit).unwrap();
580        assert!(
581            json.get("schema").is_none(),
582            "a verdict is a row; report::Report versions the document"
583        );
584        assert_eq!(json["space"], "global");
585        assert_eq!(json["hit"], true);
586        assert_eq!(json["point"]["x"], 15);
587        assert_eq!(json["contained_in"][0]["label"], "box");
588        assert!(json.get("monitor").is_none(), "global space has no monitor");
589        assert!(json.get("nearest").is_none(), "hits carry no nearest");
590
591        let miss = assess(&file, Point::new(500, 500), Origin::Monitor(0), None).unwrap();
592        let json = serde_json::to_value(&miss).unwrap();
593        assert_eq!(json["space"], "monitor");
594        assert_eq!(json["monitor"], 0);
595        assert_eq!(json["nearest"]["region"]["label"], "box");
596    }
597
598    #[test]
599    fn space_labels_name_their_own_space() {
600        assert_eq!(Origin::Global.label(), "global");
601        assert_eq!(Origin::Monitor(3).label(), "monitor");
602        assert_eq!(Origin::Window.label(), "window");
603    }
604}