Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
980779f
vcli-perception: reserve workspace member + add imageproc/dashmap deps
blaksmatic Apr 17, 2026
933f335
vcli-perception: empty crate shell with core+imageproc+dashmap deps
blaksmatic Apr 17, 2026
4f3d841
vcli-perception: PerceptionError + Result alias
blaksmatic Apr 17, 2026
d2e3cc0
vcli-perception: Evaluator trait + EvalCtx (object-safe, &self)
blaksmatic Apr 17, 2026
f4786aa
vcli-perception: PredicateCache (DashMap, per-tick clear)
blaksmatic Apr 17, 2026
277a7eb
vcli-perception: PerceptionState (prior snapshots + first-true timers)
blaksmatic Apr 17, 2026
4d7b68a
vcli-perception: frame_view (BGRA↔RGB swizzle, region crop with clipp…
blaksmatic Apr 17, 2026
8762581
vcli-perception: ColorAtEvaluator (Tier-1 pixel sample + RGB distance)
blaksmatic Apr 17, 2026
5dea44a
vcli-perception: PixelDiffEvaluator (dHash + prior-frame snapshot state)
blaksmatic Apr 17, 2026
4807a05
vcli-perception: AllOf / AnyOf / Not evaluators with shared-cache rec…
blaksmatic Apr 17, 2026
779761d
vcli-perception: ElapsedMsSinceTrueEvaluator + program-scoped EvalCtx
blaksmatic Apr 17, 2026
963a670
vcli-perception: TemplateEvaluator (imageproc NCC + bbox recovery)
blaksmatic Apr 17, 2026
465f276
vcli-perception: Perception façade — evaluate_named with cache memoiz…
blaksmatic Apr 17, 2026
6166f31
vcli-perception: integration tests for cache dedup + per-tick clear +…
blaksmatic Apr 17, 2026
79db402
vcli-perception: Task 15 verification — clippy clean, all 141 tests pass
blaksmatic Apr 17, 2026
ea5dc3a
Merge remote-tracking branch 'origin/master' into implement/plan-2-vc…
blaksmatic Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,080 changes: 1,035 additions & 45 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ members = [
"crates/vcli-core",
"crates/vcli-dsl",
"crates/vcli-ipc",
"crates/vcli-perception",
"crates/vcli-store",
]

Expand Down Expand Up @@ -33,7 +34,13 @@ rusqlite = { version = "0.31", features = ["bundled"] }
sha2 = "0.10"
hex = "0.4"

# Added by vcli-perception lane
dashmap = "6"
imageproc = "0.25"
image = { version = "0.25", default-features = false, features = ["png"] }

# Dev-only
proptest = "1"
tempfile = "3"
tokio-test = "0.4"
approx = "0.5"
22 changes: 22 additions & 0 deletions crates/vcli-perception/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "vcli-perception"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Tier-1/Tier-2 predicate evaluators + per-tick cache + cross-tick state for vcli."

[dependencies]
vcli-core = { path = "../vcli-core" }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
dashmap = { workspace = true }
imageproc = { workspace = true }
image = { workspace = true }

[dev-dependencies]
proptest = { workspace = true }
approx = { workspace = true }
130 changes: 130 additions & 0 deletions crates/vcli-perception/src/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! Per-tick predicate result cache. Decision A — `DashMap` for lock-free
//! reads and sharded writes. The runtime calls `clear()` at the start of
//! every tick so evaluators within a tick see each other's results but
//! no stale results carry across ticks.
//!
//! Program-local temporal predicates (`elapsed_ms_since_true`) keep their
//! state in `PerceptionState`, not here.

use dashmap::DashMap;
use vcli_core::{PredicateHash, PredicateResult};

/// The shared per-tick cache.
#[derive(Debug, Default)]
pub struct PredicateCache {
entries: DashMap<PredicateHash, PredicateResult>,
}

impl PredicateCache {
/// Create an empty cache.
#[must_use]
pub fn new() -> Self {
Self::default()
}

/// Read a cached result if present.
#[must_use]
pub fn get(&self, hash: &PredicateHash) -> Option<PredicateResult> {
self.entries.get(hash).map(|r| r.clone())
}

/// Store a result. If an entry already exists for this hash, it is
/// overwritten (this is the cheap retry path — it should be rare
/// because evaluators check `get` first).
pub fn insert(&self, hash: PredicateHash, result: PredicateResult) {
self.entries.insert(hash, result);
}

/// Number of entries. Diagnostic use only.
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}

/// Whether the cache is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}

/// Invalidate all entries. Called by the runtime at tick start.
pub fn clear(&self) {
self.entries.clear();
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vcli_core::predicate_hash;

fn fake_result(truthy: bool) -> PredicateResult {
PredicateResult {
truthy,
match_data: None,
at: 1000,
}
}

fn some_hash(tag: &str) -> PredicateHash {
predicate_hash(&json!({"kind": "color_at", "tag": tag})).unwrap()
}

#[test]
fn get_miss_returns_none() {
let c = PredicateCache::new();
assert!(c.get(&some_hash("a")).is_none());
assert!(c.is_empty());
}

#[test]
fn insert_then_get_returns_result() {
let c = PredicateCache::new();
let h = some_hash("a");
c.insert(h.clone(), fake_result(true));
assert_eq!(c.get(&h), Some(fake_result(true)));
assert_eq!(c.len(), 1);
}

#[test]
fn insert_overwrites_existing_entry() {
let c = PredicateCache::new();
let h = some_hash("a");
c.insert(h.clone(), fake_result(false));
c.insert(h.clone(), fake_result(true));
assert_eq!(c.get(&h), Some(fake_result(true)));
assert_eq!(c.len(), 1);
}

#[test]
fn clear_wipes_all_entries() {
let c = PredicateCache::new();
c.insert(some_hash("a"), fake_result(true));
c.insert(some_hash("b"), fake_result(false));
assert_eq!(c.len(), 2);
c.clear();
assert!(c.is_empty());
}

#[test]
fn concurrent_inserts_from_many_threads() {
use std::sync::Arc;
use std::thread;

let c = Arc::new(PredicateCache::new());
let handles: Vec<_> = (0..16)
.map(|i| {
let c = Arc::clone(&c);
thread::spawn(move || {
let tag = format!("k{i}");
c.insert(some_hash(&tag), fake_result(i % 2 == 0));
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(c.len(), 16);
}
}
183 changes: 183 additions & 0 deletions crates/vcli-perception/src/color_at.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//! `ColorAtEvaluator` — Tier 1, <1ms. Samples one pixel and checks its
//! Euclidean RGB distance from the target.

use vcli_core::predicate::{PredicateKind, Rgb};
use vcli_core::{Frame, Predicate, PredicateResult};

use crate::error::{PerceptionError, Result};
use crate::evaluator::{EvalCtx, Evaluator};
use crate::frame_view::pixel_rgb;

/// Stateless evaluator for `PredicateKind::ColorAt`.
#[derive(Debug, Default)]
pub struct ColorAtEvaluator;

impl ColorAtEvaluator {
/// Pure sampling helper. Public for tests; prefer `Evaluator::evaluate`.
///
/// # Errors
///
/// Propagates `RegionOutOfBounds` if the point is outside the frame.
#[allow(clippy::many_single_char_names)]
pub fn sample(frame: &Frame, x: i32, y: i32, target: Rgb, tolerance: u16) -> Result<bool> {
let [r, g, b] = pixel_rgb(frame, x, y)?;
let [tr, tg, tb] = target.0;
let dr = i32::from(r) - i32::from(tr);
let dg = i32::from(g) - i32::from(tg);
let db = i32::from(b) - i32::from(tb);
let dist_sq = dr * dr + dg * dg + db * db;
let tol_sq = i32::from(tolerance) * i32::from(tolerance);
Ok(dist_sq <= tol_sq)
}
}

impl Evaluator for ColorAtEvaluator {
fn evaluate(&self, predicate: &Predicate, ctx: &EvalCtx<'_>) -> Result<PredicateResult> {
let PredicateKind::ColorAt {
point,
rgb,
tolerance,
} = predicate
else {
return Err(PerceptionError::AssetDecode(
"color_at evaluator received non-color_at predicate".into(),
));
};
// Translate absolute screen point to frame-local.
let fb = ctx.frame.bounds;
let fx = point.x - fb.x;
let fy = point.y - fb.y;
let truthy = Self::sample(ctx.frame, fx, fy, *rgb, *tolerance)?;
Ok(PredicateResult {
truthy,
match_data: None,
at: ctx.now_ms,
})
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::sync::Arc;

use vcli_core::geom::{Point, Rect};
use vcli_core::predicate::Rgb;
use vcli_core::{FrameFormat, PredicateKind};

use crate::cache::PredicateCache;
use crate::state::PerceptionState;

/// 2×2 RGBA8 frame: (0,0)=red, (1,0)=green, (0,1)=blue, (1,1)=white.
fn rgba_checker() -> Frame {
let pixels: Vec<u8> = vec![
255, 0, 0, 255, // (0,0) red
0, 255, 0, 255, // (1,0) green
0, 0, 255, 255, // (0,1) blue
255, 255, 255, 255, // (1,1) white
];
Frame::new(
FrameFormat::Rgba8,
Rect {
x: 0,
y: 0,
w: 2,
h: 2,
},
8,
Arc::from(pixels),
0,
)
}

fn ctx<'a>(
frame: &'a Frame,
cache: &'a PredicateCache,
state: &'a PerceptionState,
preds: &'a BTreeMap<String, Predicate>,
assets: &'a BTreeMap<String, Vec<u8>>,
) -> EvalCtx<'a> {
EvalCtx {
frame,
now_ms: 42,
cache,
state,
predicates: preds,
assets,
program: None,
}
}

#[test]
fn exact_color_match_is_truthy() {
let f = rgba_checker();
let cache = PredicateCache::new();
let state = PerceptionState::new();
let preds = BTreeMap::new();
let assets = BTreeMap::new();
let p = PredicateKind::ColorAt {
point: Point { x: 0, y: 0 },
rgb: Rgb([255, 0, 0]),
tolerance: 0,
};
let r = ColorAtEvaluator
.evaluate(&p, &ctx(&f, &cache, &state, &preds, &assets))
.unwrap();
assert!(r.truthy);
assert_eq!(r.at, 42);
assert!(r.match_data.is_none());
}

#[test]
fn off_by_one_within_tolerance_is_truthy() {
let f = rgba_checker();
let cache = PredicateCache::new();
let state = PerceptionState::new();
let preds = BTreeMap::new();
let assets = BTreeMap::new();
let p = PredicateKind::ColorAt {
point: Point { x: 1, y: 0 },
rgb: Rgb([1, 254, 1]),
tolerance: 5,
};
let r = ColorAtEvaluator
.evaluate(&p, &ctx(&f, &cache, &state, &preds, &assets))
.unwrap();
assert!(r.truthy);
}

#[test]
fn beyond_tolerance_is_falsy() {
let f = rgba_checker();
let cache = PredicateCache::new();
let state = PerceptionState::new();
let preds = BTreeMap::new();
let assets = BTreeMap::new();
let p = PredicateKind::ColorAt {
point: Point { x: 0, y: 1 }, // blue
rgb: Rgb([255, 0, 0]), // red target
tolerance: 10,
};
let r = ColorAtEvaluator
.evaluate(&p, &ctx(&f, &cache, &state, &preds, &assets))
.unwrap();
assert!(!r.truthy);
}

#[test]
fn out_of_bounds_errors() {
let f = rgba_checker();
let cache = PredicateCache::new();
let state = PerceptionState::new();
let preds = BTreeMap::new();
let assets = BTreeMap::new();
let p = PredicateKind::ColorAt {
point: Point { x: 100, y: 100 },
rgb: Rgb([0, 0, 0]),
tolerance: 0,
};
let r = ColorAtEvaluator.evaluate(&p, &ctx(&f, &cache, &state, &preds, &assets));
assert!(matches!(r, Err(PerceptionError::RegionOutOfBounds)));
}
}
Loading
Loading