diff --git a/Cargo.lock b/Cargo.lock index a100ec9..5cf8ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,28 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -14,12 +36,71 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -37,6 +118,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -52,12 +176,27 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -67,12 +206,30 @@ dependencies = [ "generic-array", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -86,6 +243,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -95,6 +254,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -104,6 +269,46 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -114,6 +319,20 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "digest" version = "0.10.7" @@ -124,6 +343,32 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -140,6 +385,21 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -158,12 +418,51 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -192,6 +491,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -217,6 +529,27 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -268,6 +601,64 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + +[[package]] +name = "imageproc" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image", + "itertools 0.12.1", + "nalgebra", + "num", + "rand 0.8.6", + "rand_distr", + "rayon", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "indexmap" version = "2.14.0" @@ -281,71 +672,310 @@ dependencies = [ ] [[package]] -name = "itoa" -version = "1.0.18" +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] -name = "js-sys" -version = "0.3.95" +name = "num" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "once_cell", - "wasm-bindgen", + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", ] [[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.185" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] [[package]] -name = "libsqlite3-sys" -version = "0.28.0" +name = "num-complex" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "cc", - "pkg-config", - "vcpkg", + "num-traits", ] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "num-derive" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "log" -version = "0.4.29" +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] [[package]] -name = "memchr" -version = "2.8.0" +name = "num-iter" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] [[package]] -name = "mio" -version = "1.2.0" +name = "num-rational" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "libc", - "wasi", - "windows-sys", + "num-bigint", + "num-integer", + "num-traits", ] [[package]] @@ -355,6 +985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -363,6 +994,40 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -375,6 +1040,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -403,6 +1081,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "proptest" version = "1.11.0" @@ -413,8 +1110,8 @@ dependencies = [ "bit-vec", "bitflags", "num-traits", - "rand", - "rand_chacha", + "rand 0.9.4", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -422,12 +1119,33 @@ dependencies = [ "unarray", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.45" @@ -449,14 +1167,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -466,7 +1205,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -478,13 +1226,108 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error 2.0.1", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", ] [[package]] @@ -493,6 +1336,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "rusqlite" version = "0.31.0" @@ -533,7 +1382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -544,6 +1393,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -611,6 +1475,34 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -627,6 +1519,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.117" @@ -671,6 +1569,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -734,6 +1646,12 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "typenum" version = "1.19.0" @@ -779,6 +1697,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "vcli-core" version = "0.0.1" @@ -819,6 +1748,21 @@ dependencies = [ "vcli-core", ] +[[package]] +name = "vcli-perception" +version = "0.0.1" +dependencies = [ + "approx", + "dashmap", + "image", + "imageproc", + "proptest", + "serde", + "serde_json", + "thiserror", + "vcli-core", +] + [[package]] name = "vcli-store" version = "0.0.1" @@ -958,6 +1902,22 @@ dependencies = [ "semver", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1061,6 +2021,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "zerocopy" version = "0.8.48" @@ -1086,3 +2052,27 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index d736b27..635405e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/vcli-core", "crates/vcli-dsl", "crates/vcli-ipc", + "crates/vcli-perception", "crates/vcli-store", ] @@ -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" diff --git a/crates/vcli-perception/Cargo.toml b/crates/vcli-perception/Cargo.toml new file mode 100644 index 0000000..49c47c4 --- /dev/null +++ b/crates/vcli-perception/Cargo.toml @@ -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 } diff --git a/crates/vcli-perception/src/cache.rs b/crates/vcli-perception/src/cache.rs new file mode 100644 index 0000000..74d5acd --- /dev/null +++ b/crates/vcli-perception/src/cache.rs @@ -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, +} + +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 { + 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); + } +} diff --git a/crates/vcli-perception/src/color_at.rs b/crates/vcli-perception/src/color_at.rs new file mode 100644 index 0000000..7a412e1 --- /dev/null +++ b/crates/vcli-perception/src/color_at.rs @@ -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 { + 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 { + 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 = 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, + assets: &'a BTreeMap>, + ) -> 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))); + } +} diff --git a/crates/vcli-perception/src/elapsed.rs b/crates/vcli-perception/src/elapsed.rs new file mode 100644 index 0000000..adeea57 --- /dev/null +++ b/crates/vcli-perception/src/elapsed.rs @@ -0,0 +1,252 @@ +//! `ElapsedMsSinceTrueEvaluator` — Tier 1. True iff the referenced child +//! predicate has been continuously truthy for at least `ms` milliseconds. +//! +//! State is program-local (spec §"`elapsed_ms_since_true`"): timestamps +//! live in `PerceptionState::first_true_at` keyed by +//! `(program_id, predicate_name)`, not by predicate hash. Without a +//! program id in `EvalCtx`, this evaluator returns an error (the runtime +//! should never call it in that configuration). + +use vcli_core::predicate::PredicateKind; +use vcli_core::{Predicate, PredicateResult}; + +use crate::error::{PerceptionError, Result}; +use crate::evaluator::{EvalCtx, Evaluator}; +use crate::logical::crate_evaluate_named_with_depth; +use crate::state::ProgramPredKey; + +/// Evaluator for `PredicateKind::ElapsedMsSinceTrue`. +#[derive(Debug, Default)] +pub struct ElapsedMsSinceTrueEvaluator; + +impl Evaluator for ElapsedMsSinceTrueEvaluator { + fn evaluate(&self, predicate: &Predicate, ctx: &EvalCtx<'_>) -> Result { + let PredicateKind::ElapsedMsSinceTrue { + predicate: child_name, + ms, + } = predicate + else { + return Err(PerceptionError::AssetDecode( + "elapsed evaluator got wrong kind".into(), + )); + }; + + let program = ctx.program.ok_or_else(|| { + PerceptionError::AssetDecode( + "elapsed_ms_since_true evaluated outside program context".into(), + ) + })?; + + let child = crate_evaluate_named_with_depth(child_name, ctx, 0)?; + + let key = ProgramPredKey { + program, + predicate: child_name.clone(), + }; + + if child.truthy { + // Rising edge: record the first-true timestamp. No-op if already set. + ctx.state.set_first_true_at(key.clone(), ctx.now_ms); + // Now check whether we've been true long enough. + let first = ctx.state.first_true_at(&key).unwrap_or(ctx.now_ms); + let elapsed = ctx.now_ms.saturating_sub(first); + let truthy = elapsed >= i64::from(*ms); + Ok(PredicateResult { + truthy, + match_data: None, + at: ctx.now_ms, + }) + } else { + // Falling edge: clear so the next true starts a new run. + ctx.state.clear_first_true_at(&key); + Ok(PredicateResult { + truthy: false, + 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::{Frame, FrameFormat, ProgramId}; + + use crate::cache::PredicateCache; + use crate::state::PerceptionState; + + fn red_pixel_frame() -> Frame { + Frame::new( + FrameFormat::Rgba8, + Rect { + x: 0, + y: 0, + w: 1, + h: 1, + }, + 4, + Arc::from(vec![255u8, 0, 0, 255]), + 0, + ) + } + + fn make_preds(child_truthy_color: Rgb) -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert( + "red_here".into(), + PredicateKind::ColorAt { + point: Point { x: 0, y: 0 }, + rgb: child_truthy_color, + tolerance: 0, + }, + ); + m + } + + #[test] + fn first_tick_truthy_child_not_elapsed_yet() { + let state = PerceptionState::new(); + let cache = PredicateCache::new(); + let preds = make_preds(Rgb([255, 0, 0])); + let assets = BTreeMap::new(); + let frame = red_pixel_frame(); + let pid = ProgramId::new(); + let p = PredicateKind::ElapsedMsSinceTrue { + predicate: "red_here".into(), + ms: 500, + }; + let ctx = EvalCtx { + frame: &frame, + now_ms: 1000, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: Some(pid), + }; + let r = ElapsedMsSinceTrueEvaluator.evaluate(&p, &ctx).unwrap(); + assert!(!r.truthy, "0ms elapsed is not ≥500"); + } + + #[test] + fn after_threshold_elapses_returns_truthy() { + let state = PerceptionState::new(); + let cache = PredicateCache::new(); + let preds = make_preds(Rgb([255, 0, 0])); + let assets = BTreeMap::new(); + let frame = red_pixel_frame(); + let pid = ProgramId::new(); + let p = PredicateKind::ElapsedMsSinceTrue { + predicate: "red_here".into(), + ms: 500, + }; + let ctx1 = EvalCtx { + frame: &frame, + now_ms: 1000, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: Some(pid), + }; + let _ = ElapsedMsSinceTrueEvaluator.evaluate(&p, &ctx1).unwrap(); + cache.clear(); + let ctx2 = EvalCtx { + frame: &frame, + now_ms: 1500, // 500ms later, still truthy + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: Some(pid), + }; + let r = ElapsedMsSinceTrueEvaluator.evaluate(&p, &ctx2).unwrap(); + assert!(r.truthy); + } + + #[test] + fn falling_edge_clears_timer() { + let state = PerceptionState::new(); + let cache = PredicateCache::new(); + let preds_red = make_preds(Rgb([255, 0, 0])); + let preds_green = make_preds(Rgb([0, 255, 0])); + let assets = BTreeMap::new(); + let frame = red_pixel_frame(); + let pid = ProgramId::new(); + let p = PredicateKind::ElapsedMsSinceTrue { + predicate: "red_here".into(), + ms: 500, + }; + + // Tick 1: child truthy, 0ms elapsed. + let ctx = EvalCtx { + frame: &frame, + now_ms: 1000, + cache: &cache, + state: &state, + predicates: &preds_red, + assets: &assets, + program: Some(pid), + }; + let _ = ElapsedMsSinceTrueEvaluator.evaluate(&p, &ctx).unwrap(); + + // Tick 2: child becomes FALSE (expect green, frame is red). + cache.clear(); + let ctx = EvalCtx { + frame: &frame, + now_ms: 1200, + cache: &cache, + state: &state, + predicates: &preds_green, + assets: &assets, + program: Some(pid), + }; + let r = ElapsedMsSinceTrueEvaluator.evaluate(&p, &ctx).unwrap(); + assert!(!r.truthy); + + // Tick 3: back to truthy at t=2000. Should re-start the timer — + // elapsed is 0, not 1000. + cache.clear(); + let ctx = EvalCtx { + frame: &frame, + now_ms: 2000, + cache: &cache, + state: &state, + predicates: &preds_red, + assets: &assets, + program: Some(pid), + }; + let r = ElapsedMsSinceTrueEvaluator.evaluate(&p, &ctx).unwrap(); + assert!(!r.truthy, "timer restarted after falling edge"); + } + + #[test] + fn no_program_id_errors() { + let state = PerceptionState::new(); + let cache = PredicateCache::new(); + let preds = make_preds(Rgb([255, 0, 0])); + let assets = BTreeMap::new(); + let frame = red_pixel_frame(); + let p = PredicateKind::ElapsedMsSinceTrue { + predicate: "red_here".into(), + ms: 500, + }; + let ctx = EvalCtx { + frame: &frame, + now_ms: 1000, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: None, + }; + let r = ElapsedMsSinceTrueEvaluator.evaluate(&p, &ctx); + assert!(matches!(r, Err(PerceptionError::AssetDecode(_)))); + } +} diff --git a/crates/vcli-perception/src/error.rs b/crates/vcli-perception/src/error.rs new file mode 100644 index 0000000..2328d53 --- /dev/null +++ b/crates/vcli-perception/src/error.rs @@ -0,0 +1,55 @@ +//! Perception error type. Evaluators surface a fast-path `PredicateResult` +//! for "predicate is false because inputs disagree" (not an error), and +//! return `PerceptionError` only for conditions the runtime must handle +//! (bad asset bytes, out-of-bounds region, unknown referenced predicate). + +use thiserror::Error; + +/// Alias for `Result`. +pub type Result = std::result::Result; + +/// Errors surfaced by evaluators. +#[derive(Debug, Error)] +pub enum PerceptionError { + /// A logical predicate referenced a name not present in the program. + #[error("unknown predicate reference: {0}")] + UnknownPredicate(String), + /// A predicate referenced itself directly or transitively. The DSL + /// validator should catch this at submit; the evaluator double-checks. + #[error("cycle detected at predicate: {0}")] + Cycle(String), + /// Asset bytes could not be decoded. + #[error("asset decode: {0}")] + AssetDecode(String), + /// Region is outside the frame bounds (partial overlap is clipped, but + /// zero overlap is reported). + #[error("region outside frame bounds")] + RegionOutOfBounds, + /// A `sha256:` asset reference was passed to an evaluator without + /// a resolver having first attached the raw bytes. Bug, not user error. + #[error("asset bytes not materialized for reference: {0}")] + AssetNotMaterialized(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_unknown_predicate() { + let e = PerceptionError::UnknownPredicate("foo".into()); + assert_eq!(e.to_string(), "unknown predicate reference: foo"); + } + + #[test] + fn display_cycle() { + let e = PerceptionError::Cycle("a".into()); + assert_eq!(e.to_string(), "cycle detected at predicate: a"); + } + + #[test] + fn display_asset_decode() { + let e = PerceptionError::AssetDecode("bad PNG".into()); + assert!(e.to_string().contains("bad PNG")); + } +} diff --git a/crates/vcli-perception/src/evaluator.rs b/crates/vcli-perception/src/evaluator.rs new file mode 100644 index 0000000..7caa34a --- /dev/null +++ b/crates/vcli-perception/src/evaluator.rs @@ -0,0 +1,77 @@ +//! The `Evaluator` trait and its evaluation context. +//! +//! Per Decision A, `evaluate` takes `&self` so multiple evaluators can run +//! concurrently under `rayon::par_iter` with a shared `DashMap` cache. +//! Evaluators that need mutable state (`pixel_diff` prior frames, elapsed +//! first-true timestamps) push that state into `PerceptionState`, which +//! uses interior mutability (`DashMap` / `Mutex`) — never `&mut self`. + +use std::collections::BTreeMap; + +use vcli_core::clock::UnixMs; +use vcli_core::{Frame, Predicate, PredicateResult}; + +use crate::cache::PredicateCache; +use crate::error::Result; +use crate::state::PerceptionState; + +/// Evaluation context threaded to every `Evaluator::evaluate` call. +/// +/// Lifetime: exactly one tick. The cache is per-tick; the state is +/// cross-tick but shared by reference here. +pub struct EvalCtx<'a> { + /// Captured frame for this tick. + pub frame: &'a Frame, + /// Unix-ms timestamp of this tick (from the `Clock`). + pub now_ms: UnixMs, + /// Per-tick cache (cleared by the runtime at tick start). + pub cache: &'a PredicateCache, + /// Cross-tick state (interior mutability). + pub state: &'a PerceptionState, + /// Named predicate graph for this program. Logical and + /// `elapsed_ms_since_true` evaluators look up dependencies here. + pub predicates: &'a BTreeMap, + /// Asset bytes, keyed by sha256 hex (no `sha256:` prefix). Populated + /// by the daemon submit module before handing off to Perception. + pub assets: &'a BTreeMap>, + /// The program currently being evaluated. Only used by predicates with + /// program-local state (`elapsed_ms_since_true`). `None` when the + /// runtime evaluates a trigger-independent predicate. + pub program: Option, +} + +/// A predicate evaluator. One impl per predicate kind. +pub trait Evaluator: Send + Sync { + /// Evaluate the predicate against the current frame + state. + /// + /// # Errors + /// + /// Returns `PerceptionError` only for conditions the runtime must + /// handle (bad asset, region out of bounds, unknown reference). + /// A "predicate is false because screen doesn't match" is NOT an + /// error — it returns `Ok(PredicateResult { truthy: false, ... })`. + fn evaluate(&self, predicate: &Predicate, ctx: &EvalCtx<'_>) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + // Compile-time test: a no-op evaluator implements the trait. + struct NoOp; + impl Evaluator for NoOp { + fn evaluate(&self, _p: &Predicate, ctx: &EvalCtx<'_>) -> Result { + Ok(PredicateResult { + truthy: false, + match_data: None, + at: ctx.now_ms, + }) + } + } + + #[test] + fn trait_is_object_safe_and_send_sync() { + // Compiles only if Evaluator is object-safe and Send + Sync. + let _boxed: Box = Box::new(NoOp); + } +} diff --git a/crates/vcli-perception/src/frame_view.rs b/crates/vcli-perception/src/frame_view.rs new file mode 100644 index 0000000..6554cac --- /dev/null +++ b/crates/vcli-perception/src/frame_view.rs @@ -0,0 +1,224 @@ +//! Low-level accessors that bridge `vcli-core::Frame` (BGRA8 / RGBA8, +//! possibly with stride padding) to `image::RgbImage` slices used by +//! `imageproc` and the `pixel_diff` dHash. +//! +//! All evaluators go through this module so the BGRA↔RGB swizzle and the +//! Frame-bounds-vs-region clipping logic lives in exactly one place. + +use image::{ImageBuffer, Rgb, RgbImage}; + +use vcli_core::geom::Rect; +use vcli_core::{Frame, FrameFormat}; + +use crate::error::{PerceptionError, Result}; + +/// Read the RGB value at `(x, y)` in frame-local (not screen) coords. +/// +/// # Errors +/// +/// Returns `RegionOutOfBounds` if the coordinate is outside the frame. +pub fn pixel_rgb(frame: &Frame, x: i32, y: i32) -> Result<[u8; 3]> { + if x < 0 || y < 0 || x >= frame.width() || y >= frame.height() { + return Err(PerceptionError::RegionOutOfBounds); + } + // Safety: x and y are both non-negative (checked above). + #[allow(clippy::cast_sign_loss)] + let ux = x as usize; + #[allow(clippy::cast_sign_loss)] + let uy = y as usize; + let bpp = frame.format.bytes_per_pixel(); + let offset = uy.saturating_mul(frame.stride) + ux.saturating_mul(bpp); + let bytes = &frame.pixels[offset..offset + bpp]; + Ok(match frame.format { + // BGRA8: B, G, R, A + FrameFormat::Bgra8 => [bytes[2], bytes[1], bytes[0]], + // RGBA8: R, G, B, A + FrameFormat::Rgba8 => [bytes[0], bytes[1], bytes[2]], + }) +} + +/// Crop a rectangle out of the frame and return it as an `RgbImage`. +/// `region_abs` is in absolute screen coords; this function translates +/// them into frame-local coords using `frame.bounds.top_left()`. +/// +/// If the region partially overlaps, returns the overlap. If there is +/// zero overlap, returns `RegionOutOfBounds`. +/// +/// # Errors +/// +/// `RegionOutOfBounds` if the region does not intersect the frame. +pub fn crop_rgb(frame: &Frame, region_abs: Rect) -> Result { + let fb = frame.bounds; + let x0 = region_abs.x.max(fb.x); + let y0 = region_abs.y.max(fb.y); + let x1 = (region_abs.x + region_abs.w).min(fb.x + fb.w); + let y1 = (region_abs.y + region_abs.h).min(fb.y + fb.h); + if x1 <= x0 || y1 <= y0 { + return Err(PerceptionError::RegionOutOfBounds); + } + // w and h are differences of clamped i32 coords, both non-negative. + #[allow(clippy::cast_sign_loss)] + let w = (x1 - x0) as u32; + #[allow(clippy::cast_sign_loss)] + let h = (y1 - y0) as u32; + let mut out = ImageBuffer::, Vec>::new(w, h); + for row in 0..h { + for col in 0..w { + // col and row are u32 from 0..w/h; guaranteed to fit in i32 for + // practical frame dimensions (< 2^31 pixels). + #[allow(clippy::cast_possible_wrap)] + let fx = (x0 - fb.x) + col as i32; + #[allow(clippy::cast_possible_wrap)] + let fy = (y0 - fb.y) + row as i32; + let rgb = pixel_rgb(frame, fx, fy)?; + out.put_pixel(col, row, Rgb(rgb)); + } + } + Ok(out) +} + +/// Convert the full frame into an owned `RgbImage`. Used by +/// `TemplateEvaluator` when the region covers the whole frame. +/// +/// # Errors +/// +/// Propagates `pixel_rgb` errors (unreachable for in-bounds iteration). +pub fn frame_to_rgb(frame: &Frame) -> Result { + // width() and height() return i32 which are non-negative for valid frames. + #[allow(clippy::cast_sign_loss)] + let w = frame.width() as u32; + #[allow(clippy::cast_sign_loss)] + let h = frame.height() as u32; + let mut out = ImageBuffer::, Vec>::new(w, h); + for y in 0..frame.height() { + for x in 0..frame.width() { + let rgb = pixel_rgb(frame, x, y)?; + // x and y are non-negative (0..positive_i32). + #[allow(clippy::cast_sign_loss)] + let px = x as u32; + #[allow(clippy::cast_sign_loss)] + let py = y as u32; + out.put_pixel(px, py, Rgb(rgb)); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + /// Build a 4×2 BGRA8 frame where pixel (x, y) = RGB (10·x, 20·y, x + y). + fn bgra_test_frame() -> Frame { + let w = 4usize; + let h = 2usize; + let stride = w * 4; + let mut pixels = vec![0u8; stride * h]; + for y in 0..h { + for x in 0..w { + let off = y * stride + x * 4; + // Test values are small (x<4, y<2) so casts are safe. + #[allow(clippy::cast_possible_truncation)] + { + pixels[off] = (x + y) as u8; // B + pixels[off + 1] = (20 * y) as u8; // G + pixels[off + 2] = (10 * x) as u8; // R + } + pixels[off + 3] = 255; + } + } + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + Frame::new( + FrameFormat::Bgra8, + Rect { + x: 0, + y: 0, + w: w as i32, + h: h as i32, + }, + stride, + Arc::from(pixels), + 0, + ) + } + + #[test] + fn pixel_rgb_bgra_swizzles_to_rgb() { + let f = bgra_test_frame(); + assert_eq!(pixel_rgb(&f, 0, 0).unwrap(), [0, 0, 0]); + assert_eq!(pixel_rgb(&f, 3, 1).unwrap(), [30, 20, 4]); + } + + #[test] + fn pixel_rgb_out_of_bounds_errors() { + let f = bgra_test_frame(); + assert!(matches!( + pixel_rgb(&f, -1, 0), + Err(PerceptionError::RegionOutOfBounds) + )); + assert!(matches!( + pixel_rgb(&f, 0, 10), + Err(PerceptionError::RegionOutOfBounds) + )); + } + + #[test] + fn crop_rgb_returns_correct_dimensions() { + let f = bgra_test_frame(); + let crop = crop_rgb( + &f, + Rect { + x: 1, + y: 0, + w: 2, + h: 2, + }, + ) + .unwrap(); + assert_eq!(crop.width(), 2); + assert_eq!(crop.height(), 2); + // (1, 0) = R 10, G 0, B 1 + assert_eq!(crop.get_pixel(0, 0).0, [10, 0, 1]); + } + + #[test] + fn crop_rgb_clips_to_frame_bounds() { + let f = bgra_test_frame(); + let crop = crop_rgb( + &f, + Rect { + x: 3, + y: 1, + w: 10, + h: 10, + }, + ) + .unwrap(); + assert_eq!(crop.width(), 1); + assert_eq!(crop.height(), 1); + } + + #[test] + fn crop_rgb_zero_overlap_errors() { + let f = bgra_test_frame(); + let crop = crop_rgb( + &f, + Rect { + x: 100, + y: 100, + w: 10, + h: 10, + }, + ); + assert!(matches!(crop, Err(PerceptionError::RegionOutOfBounds))); + } + + #[test] + fn frame_to_rgb_has_correct_dimensions() { + let f = bgra_test_frame(); + let img = frame_to_rgb(&f).unwrap(); + assert_eq!(img.width(), 4); + assert_eq!(img.height(), 2); + } +} diff --git a/crates/vcli-perception/src/lib.rs b/crates/vcli-perception/src/lib.rs new file mode 100644 index 0000000..fe4faac --- /dev/null +++ b/crates/vcli-perception/src/lib.rs @@ -0,0 +1,47 @@ +//! vcli-perception — Tier-1 and Tier-2 predicate evaluators for vcli. +//! +//! See `docs/superpowers/specs/2026-04-16-vcli-design.md` §"Perception pipeline" +//! and Decision A (`DashMap` cache, `&self` eval under `par_iter`). + +#![forbid(unsafe_code)] +#![deny(rust_2018_idioms)] +#![warn(missing_docs)] +#![warn(clippy::pedantic)] +#![allow(clippy::module_name_repetitions)] + +pub mod error; + +pub use error::{PerceptionError, Result}; + +pub mod evaluator; + +pub use evaluator::{EvalCtx, Evaluator}; + +pub mod cache; +pub mod state; + +pub mod frame_view; + +pub mod color_at; + +pub use color_at::ColorAtEvaluator; + +pub mod pixel_diff; + +pub use pixel_diff::PixelDiffEvaluator; + +pub mod logical; + +pub use logical::{AllOfEvaluator, AnyOfEvaluator, NotEvaluator}; + +pub mod elapsed; + +pub use elapsed::ElapsedMsSinceTrueEvaluator; + +pub mod template; + +pub use template::TemplateEvaluator; + +pub mod perception; + +pub use perception::Perception; diff --git a/crates/vcli-perception/src/logical.rs b/crates/vcli-perception/src/logical.rs new file mode 100644 index 0000000..59c8b5f --- /dev/null +++ b/crates/vcli-perception/src/logical.rs @@ -0,0 +1,348 @@ +//! Logical composition evaluators — Tier 1. `all_of`, `any_of`, `not` +//! recurse through named predicates in `EvalCtx::predicates`, threading +//! the shared cache so siblings sharing a dependency pay only once. +//! +//! These are the first evaluators that _call back_ into the perception +//! layer for their children. They use a depth-bounded recursion guard so +//! a malformed program (which the DSL validator should have caught at +//! submit) can't crash the daemon. + +use vcli_core::predicate::PredicateKind; +use vcli_core::{predicate_hash, Predicate, PredicateHash, PredicateResult}; + +use crate::error::{PerceptionError, Result}; +use crate::evaluator::{EvalCtx, Evaluator}; + +const MAX_RECURSION_DEPTH: u32 = 32; + +/// Look up a named predicate, evaluate it through the correct kind's +/// evaluator, memoize in the cache, and return the result. Used by +/// logical + `elapsed_ms_since_true` evaluators. +/// +/// # Errors +/// +/// - `UnknownPredicate` if `name` is not in `ctx.predicates`. +/// - `Cycle` if recursion exceeds `MAX_RECURSION_DEPTH`. +/// - Any error the child evaluator produces. +pub(crate) fn evaluate_named_with_depth( + name: &str, + ctx: &EvalCtx<'_>, + depth: u32, +) -> Result { + if depth >= MAX_RECURSION_DEPTH { + return Err(PerceptionError::Cycle(name.into())); + } + let pred = ctx + .predicates + .get(name) + .ok_or_else(|| PerceptionError::UnknownPredicate(name.into()))?; + + let hash = hash_predicate(pred)?; + if let Some(cached) = ctx.cache.get(&hash) { + return Ok(cached); + } + + let result = match pred { + PredicateKind::AllOf { of } => eval_all_of(of, ctx, depth + 1)?, + PredicateKind::AnyOf { of } => eval_any_of(of, ctx, depth + 1)?, + PredicateKind::Not { of } => eval_not(of, ctx, depth + 1)?, + PredicateKind::ElapsedMsSinceTrue { .. } + | PredicateKind::ColorAt { .. } + | PredicateKind::PixelDiff { .. } + | PredicateKind::Template { .. } => { + // Non-logical kinds are dispatched by the Perception façade. + // `evaluate_named_with_depth` is only called from logical / + // elapsed contexts; those callers dispatch through the + // façade (see `perception.rs`), which handles all kinds. + crate::perception::dispatch_leaf(pred, ctx)? + } + }; + + ctx.cache.insert(hash, result.clone()); + Ok(result) +} + +fn hash_predicate(p: &Predicate) -> Result { + let v = serde_json::to_value(p) + .map_err(|e| PerceptionError::AssetDecode(format!("serialize predicate for hash: {e}")))?; + predicate_hash(&v).map_err(|e| PerceptionError::AssetDecode(format!("hash predicate: {e}"))) +} + +fn eval_all_of(of: &[String], ctx: &EvalCtx<'_>, depth: u32) -> Result { + // Short-circuit at first falsy child. + for name in of { + let r = evaluate_named_with_depth(name, ctx, depth)?; + if !r.truthy { + return Ok(PredicateResult { + truthy: false, + match_data: None, + at: ctx.now_ms, + }); + } + } + Ok(PredicateResult { + truthy: true, + match_data: None, + at: ctx.now_ms, + }) +} + +fn eval_any_of(of: &[String], ctx: &EvalCtx<'_>, depth: u32) -> Result { + for name in of { + let r = evaluate_named_with_depth(name, ctx, depth)?; + if r.truthy { + return Ok(PredicateResult { + truthy: true, + match_data: None, + at: ctx.now_ms, + }); + } + } + Ok(PredicateResult { + truthy: false, + match_data: None, + at: ctx.now_ms, + }) +} + +fn eval_not(name: &str, ctx: &EvalCtx<'_>, depth: u32) -> Result { + let child = evaluate_named_with_depth(name, ctx, depth)?; + Ok(PredicateResult { + truthy: !child.truthy, + match_data: None, + at: ctx.now_ms, + }) +} + +/// Public evaluator for `all_of`. Delegates to the shared traversal. +#[derive(Debug, Default)] +pub struct AllOfEvaluator; + +impl Evaluator for AllOfEvaluator { + fn evaluate(&self, predicate: &Predicate, ctx: &EvalCtx<'_>) -> Result { + let PredicateKind::AllOf { of } = predicate else { + return Err(PerceptionError::AssetDecode( + "all_of evaluator got wrong kind".into(), + )); + }; + eval_all_of(of, ctx, 0) + } +} + +/// Public evaluator for `any_of`. +#[derive(Debug, Default)] +pub struct AnyOfEvaluator; + +impl Evaluator for AnyOfEvaluator { + fn evaluate(&self, predicate: &Predicate, ctx: &EvalCtx<'_>) -> Result { + let PredicateKind::AnyOf { of } = predicate else { + return Err(PerceptionError::AssetDecode( + "any_of evaluator got wrong kind".into(), + )); + }; + eval_any_of(of, ctx, 0) + } +} + +/// Public evaluator for `not`. +#[derive(Debug, Default)] +pub struct NotEvaluator; + +impl Evaluator for NotEvaluator { + fn evaluate(&self, predicate: &Predicate, ctx: &EvalCtx<'_>) -> Result { + let PredicateKind::Not { of } = predicate else { + return Err(PerceptionError::AssetDecode( + "not evaluator got wrong kind".into(), + )); + }; + eval_not(of, ctx, 0) + } +} + +/// Re-export for `elapsed.rs` which needs to call back into the logical +/// recursion machinery with a depth argument. +pub(crate) use evaluate_named_with_depth as crate_evaluate_named_with_depth; + +#[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::{Frame, FrameFormat}; + + use crate::cache::PredicateCache; + use crate::state::PerceptionState; + + fn red_pixel_frame() -> Frame { + let pixels: Vec = vec![255, 0, 0, 255]; + Frame::new( + FrameFormat::Rgba8, + Rect { + x: 0, + y: 0, + w: 1, + h: 1, + }, + 4, + Arc::from(pixels), + 0, + ) + } + + fn build_preds() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert( + "red_here".into(), + PredicateKind::ColorAt { + point: Point { x: 0, y: 0 }, + rgb: Rgb([255, 0, 0]), + tolerance: 0, + }, + ); + m.insert( + "green_here".into(), + PredicateKind::ColorAt { + point: Point { x: 0, y: 0 }, + rgb: Rgb([0, 255, 0]), + tolerance: 0, + }, + ); + m + } + + fn make_ctx<'a>( + frame: &'a Frame, + cache: &'a PredicateCache, + state: &'a PerceptionState, + preds: &'a BTreeMap, + assets: &'a BTreeMap>, + ) -> EvalCtx<'a> { + EvalCtx { + frame, + now_ms: 10, + cache, + state, + predicates: preds, + assets, + program: None, + } + } + + #[test] + fn not_red_when_actually_red_is_false() { + let f = red_pixel_frame(); + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = build_preds(); + let assets = BTreeMap::new(); + let p = PredicateKind::Not { + of: "red_here".into(), + }; + let r = NotEvaluator + .evaluate(&p, &make_ctx(&f, &cache, &state, &preds, &assets)) + .unwrap(); + assert!(!r.truthy); + } + + #[test] + fn all_of_true_when_all_children_true() { + let f = red_pixel_frame(); + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = build_preds(); + let assets = BTreeMap::new(); + let p = PredicateKind::AllOf { + of: vec!["red_here".into(), "red_here".into()], + }; + let r = AllOfEvaluator + .evaluate(&p, &make_ctx(&f, &cache, &state, &preds, &assets)) + .unwrap(); + assert!(r.truthy); + } + + #[test] + fn all_of_false_if_any_child_false() { + let f = red_pixel_frame(); + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = build_preds(); + let assets = BTreeMap::new(); + let p = PredicateKind::AllOf { + of: vec!["red_here".into(), "green_here".into()], + }; + let r = AllOfEvaluator + .evaluate(&p, &make_ctx(&f, &cache, &state, &preds, &assets)) + .unwrap(); + assert!(!r.truthy); + } + + #[test] + fn any_of_true_if_any_child_true() { + let f = red_pixel_frame(); + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = build_preds(); + let assets = BTreeMap::new(); + let p = PredicateKind::AnyOf { + of: vec!["green_here".into(), "red_here".into()], + }; + let r = AnyOfEvaluator + .evaluate(&p, &make_ctx(&f, &cache, &state, &preds, &assets)) + .unwrap(); + assert!(r.truthy); + } + + #[test] + fn any_of_empty_list_is_false() { + let f = red_pixel_frame(); + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = build_preds(); + let assets = BTreeMap::new(); + let p = PredicateKind::AnyOf { of: vec![] }; + let r = AnyOfEvaluator + .evaluate(&p, &make_ctx(&f, &cache, &state, &preds, &assets)) + .unwrap(); + assert!(!r.truthy); + } + + #[test] + fn unknown_named_predicate_errors() { + let f = red_pixel_frame(); + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = build_preds(); + let assets = BTreeMap::new(); + let p = PredicateKind::Not { + of: "does_not_exist".into(), + }; + let r = NotEvaluator.evaluate(&p, &make_ctx(&f, &cache, &state, &preds, &assets)); + assert!(matches!(r, Err(PerceptionError::UnknownPredicate(_)))); + } + + #[test] + fn logical_shares_cache_across_siblings() { + // Evaluate (red_here AND red_here) — the second call should hit the + // cache. We detect this by wrapping ColorAtEvaluator in a counter? + // Simpler: assert the cache grows to at most 2 entries (red_here + // result + the all_of result). + let f = red_pixel_frame(); + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = build_preds(); + let assets = BTreeMap::new(); + let p = PredicateKind::AllOf { + of: vec!["red_here".into(), "red_here".into()], + }; + let _ = AllOfEvaluator + .evaluate(&p, &make_ctx(&f, &cache, &state, &preds, &assets)) + .unwrap(); + // Cache should contain exactly one entry for `red_here`. The + // `all_of` result is inserted by the Perception façade, not by the + // logical evaluator itself. + assert_eq!(cache.len(), 1, "red_here cached once, not twice"); + } +} diff --git a/crates/vcli-perception/src/perception.rs b/crates/vcli-perception/src/perception.rs new file mode 100644 index 0000000..a6cf84f --- /dev/null +++ b/crates/vcli-perception/src/perception.rs @@ -0,0 +1,302 @@ +//! Public façade. The runtime asks for a named predicate's result; the +//! façade canonicalizes, checks the cache, dispatches to the right +//! evaluator, memoizes the result, and returns it. Cache is wiped at +//! tick boundaries by the runtime calling `PredicateCache::clear()`. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use vcli_core::clock::UnixMs; +use vcli_core::predicate::PredicateKind; +use vcli_core::{predicate_hash, Frame, Predicate, PredicateHash, PredicateResult, ProgramId}; + +use crate::cache::PredicateCache; +use crate::color_at::ColorAtEvaluator; +use crate::elapsed::ElapsedMsSinceTrueEvaluator; +use crate::error::{PerceptionError, Result}; +use crate::evaluator::{EvalCtx, Evaluator}; +use crate::logical::{AllOfEvaluator, AnyOfEvaluator, NotEvaluator}; +use crate::pixel_diff::PixelDiffEvaluator; +use crate::state::PerceptionState; +use crate::template::TemplateEvaluator; + +/// Top-level perception entry point. Holds the per-tick cache and a handle +/// to cross-tick state. +pub struct Perception { + cache: Arc, + state: Arc, +} + +impl Perception { + /// Create a fresh perception with empty cache + state. + #[must_use] + pub fn new() -> Self { + Self { + cache: Arc::new(PredicateCache::new()), + state: Arc::new(PerceptionState::new()), + } + } + + /// Reuse existing shared state (e.g. after a runtime restart that + /// wants to preserve first-true timers across a short gap). + #[must_use] + pub fn with_state(state: Arc) -> Self { + Self { + cache: Arc::new(PredicateCache::new()), + state, + } + } + + /// Borrow the cache. The runtime calls `.clear()` at tick start. + #[must_use] + pub fn cache(&self) -> &PredicateCache { + &self.cache + } + + /// Borrow the cross-tick state. Diagnostic use only. + #[must_use] + pub fn state(&self) -> &PerceptionState { + &self.state + } + + /// Wipe the per-tick cache. Called by the runtime at the start of + /// every tick. Cross-tick state is preserved. + pub fn clear(&self) { + self.cache.clear(); + } + + /// Evaluate a named predicate, memoizing through the shared cache. + /// + /// # Errors + /// + /// Propagates evaluator errors — see `PerceptionError`. + pub fn evaluate_named( + &self, + name: &str, + predicates: &BTreeMap, + frame: &Frame, + now_ms: UnixMs, + assets: &BTreeMap>, + program: Option, + ) -> Result { + let pred = predicates + .get(name) + .ok_or_else(|| PerceptionError::UnknownPredicate(name.into()))?; + let ctx = EvalCtx { + frame, + now_ms, + cache: &self.cache, + state: &self.state, + predicates, + assets, + program, + }; + let hash = hash_predicate(pred)?; + if let Some(cached) = self.cache.get(&hash) { + return Ok(cached); + } + let result = dispatch(pred, &ctx)?; + self.cache.insert(hash, result.clone()); + Ok(result) + } +} + +impl Default for Perception { + fn default() -> Self { + Self::new() + } +} + +/// Hash any predicate via the canonical JSON pipeline in `vcli-core`. +pub(crate) fn hash_predicate(p: &Predicate) -> Result { + let v = serde_json::to_value(p) + .map_err(|e| PerceptionError::AssetDecode(format!("serialize predicate for hash: {e}")))?; + predicate_hash(&v).map_err(|e| PerceptionError::AssetDecode(format!("hash predicate: {e}"))) +} + +/// Dispatch a predicate to its evaluator. Internal — logical evaluators +/// call `dispatch_leaf` for non-logical kinds while they handle their +/// own recursion. +pub(crate) fn dispatch(p: &Predicate, ctx: &EvalCtx<'_>) -> Result { + match p { + PredicateKind::ColorAt { .. } => ColorAtEvaluator.evaluate(p, ctx), + PredicateKind::PixelDiff { .. } => PixelDiffEvaluator.evaluate(p, ctx), + PredicateKind::Template { .. } => TemplateEvaluator.evaluate(p, ctx), + PredicateKind::AllOf { .. } => AllOfEvaluator.evaluate(p, ctx), + PredicateKind::AnyOf { .. } => AnyOfEvaluator.evaluate(p, ctx), + PredicateKind::Not { .. } => NotEvaluator.evaluate(p, ctx), + PredicateKind::ElapsedMsSinceTrue { .. } => ElapsedMsSinceTrueEvaluator.evaluate(p, ctx), + } +} + +/// Non-logical dispatch. Called by `logical.rs` during recursion. +pub(crate) fn dispatch_leaf(p: &Predicate, ctx: &EvalCtx<'_>) -> Result { + match p { + PredicateKind::ColorAt { .. } => ColorAtEvaluator.evaluate(p, ctx), + PredicateKind::PixelDiff { .. } => PixelDiffEvaluator.evaluate(p, ctx), + PredicateKind::Template { .. } => TemplateEvaluator.evaluate(p, ctx), + PredicateKind::ElapsedMsSinceTrue { .. } => ElapsedMsSinceTrueEvaluator.evaluate(p, ctx), + PredicateKind::AllOf { .. } | PredicateKind::AnyOf { .. } | PredicateKind::Not { .. } => { + // Logical kinds shouldn't flow through dispatch_leaf — the + // logical evaluators handle their own sub-children. Defensive + // fallback so we don't silently return the wrong thing. + dispatch(p, ctx) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use vcli_core::geom::{Point, Rect}; + use vcli_core::predicate::Rgb; + use vcli_core::{Frame, FrameFormat}; + + fn red_frame() -> Frame { + Frame::new( + FrameFormat::Rgba8, + Rect { + x: 0, + y: 0, + w: 1, + h: 1, + }, + 4, + Arc::from(vec![255u8, 0, 0, 255]), + 0, + ) + } + + #[test] + fn evaluate_named_returns_truthy_for_matching_color() { + let p = Perception::new(); + let mut preds = BTreeMap::new(); + preds.insert( + "red_here".into(), + PredicateKind::ColorAt { + point: Point { x: 0, y: 0 }, + rgb: Rgb([255, 0, 0]), + tolerance: 0, + }, + ); + let assets = BTreeMap::new(); + let frame = red_frame(); + let r = p + .evaluate_named("red_here", &preds, &frame, 123, &assets, None) + .unwrap(); + assert!(r.truthy); + assert_eq!(r.at, 123); + } + + #[test] + fn evaluate_named_unknown_errors() { + let p = Perception::new(); + let preds = BTreeMap::new(); + let assets = BTreeMap::new(); + let frame = red_frame(); + let r = p.evaluate_named("nope", &preds, &frame, 0, &assets, None); + assert!(matches!(r, Err(PerceptionError::UnknownPredicate(_)))); + } + + #[test] + fn second_call_for_same_name_is_a_cache_hit() { + let p = Perception::new(); + let mut preds = BTreeMap::new(); + preds.insert( + "red_here".into(), + PredicateKind::ColorAt { + point: Point { x: 0, y: 0 }, + rgb: Rgb([255, 0, 0]), + tolerance: 0, + }, + ); + let assets = BTreeMap::new(); + let frame = red_frame(); + let r1 = p + .evaluate_named("red_here", &preds, &frame, 100, &assets, None) + .unwrap(); + let r2 = p + .evaluate_named("red_here", &preds, &frame, 200, &assets, None) + .unwrap(); + // Same hash → same cached result → `at` timestamp preserved from + // first eval, not updated to 200. This proves the cache hit. + assert_eq!(r1.at, 100); + assert_eq!(r2.at, 100); + } + + #[test] + fn clear_wipes_cache_but_not_state() { + let p = Perception::new(); + let mut preds = BTreeMap::new(); + preds.insert( + "red_here".into(), + PredicateKind::ColorAt { + point: Point { x: 0, y: 0 }, + rgb: Rgb([255, 0, 0]), + tolerance: 0, + }, + ); + let assets = BTreeMap::new(); + let frame = red_frame(); + let _ = p + .evaluate_named("red_here", &preds, &frame, 100, &assets, None) + .unwrap(); + assert_eq!(p.cache().len(), 1); + p.clear(); + assert_eq!(p.cache().len(), 0); + // State is preserved — recording a snapshot then clearing cache + // keeps the snapshot accessible. + let h = super::hash_predicate(&PredicateKind::ColorAt { + point: Point { x: 0, y: 0 }, + rgb: Rgb([255, 0, 0]), + tolerance: 0, + }) + .unwrap(); + p.state().record_snapshot( + h.clone(), + crate::state::PriorSnapshot { + dhash: 42, + at_ms: 1000, + }, + ); + p.clear(); + assert!(p.state().prior_snapshot(&h).is_some()); + } + + #[test] + fn two_programs_with_same_predicate_share_cache() { + // If two programs both reference a predicate with the same canonical + // JSON form (same kind, same params), the second evaluation should + // hit the cache. + let p = Perception::new(); + let mut preds = BTreeMap::new(); + preds.insert( + "a".into(), + PredicateKind::ColorAt { + point: Point { x: 0, y: 0 }, + rgb: Rgb([255, 0, 0]), + tolerance: 0, + }, + ); + preds.insert( + "b_also_red".into(), + PredicateKind::ColorAt { + point: Point { x: 0, y: 0 }, + rgb: Rgb([255, 0, 0]), + tolerance: 0, + }, + ); + let assets = BTreeMap::new(); + let frame = red_frame(); + let _ = p + .evaluate_named("a", &preds, &frame, 100, &assets, None) + .unwrap(); + let _ = p + .evaluate_named("b_also_red", &preds, &frame, 200, &assets, None) + .unwrap(); + // Only one entry — both names hashed to the same PredicateHash. + assert_eq!(p.cache().len(), 1); + } +} diff --git a/crates/vcli-perception/src/pixel_diff.rs b/crates/vcli-perception/src/pixel_diff.rs new file mode 100644 index 0000000..be25ea1 --- /dev/null +++ b/crates/vcli-perception/src/pixel_diff.rs @@ -0,0 +1,351 @@ +//! `PixelDiffEvaluator` — Tier 1. Perceptual-hash (dHash) over a region, +//! compared to a prior-tick hash stored in `PerceptionState`. +//! +//! Spec §"Color / pixel diff": dHash over the region + Hamming distance +//! against the baseline. In v0 the "baseline" is the previous tick's +//! snapshot of the same region (this is what gives us a cheap motion +//! detector). When no prior snapshot exists, the predicate is falsy and +//! we just record the baseline for next tick. + +use image::imageops::FilterType; +use image::{imageops, GrayImage}; +use serde_json::json; + +use vcli_core::predicate::PredicateKind; +use vcli_core::{canonicalize, Predicate, PredicateResult}; + +use crate::error::{PerceptionError, Result}; +use crate::evaluator::{EvalCtx, Evaluator}; +use crate::frame_view::crop_rgb; +use crate::state::PriorSnapshot; + +/// Stateless evaluator for `PredicateKind::PixelDiff`. State lives in +/// `PerceptionState::prior_snapshots`. +#[derive(Debug, Default)] +pub struct PixelDiffEvaluator; + +impl PixelDiffEvaluator { + /// Compute a 64-bit dHash over a grayscale thumbnail (9×8 resample). + /// Bit `i` is set iff pixel `(x, y)` is brighter than `(x+1, y)` for + /// the 8×8 block produced by taking the 9×8 differences. + #[must_use] + pub fn dhash(gray: &GrayImage) -> u64 { + let resized = imageops::resize(gray, 9, 8, FilterType::Lanczos3); + let mut h: u64 = 0; + for y in 0..8u32 { + for x in 0..8u32 { + let l = resized.get_pixel(x, y).0[0]; + let r = resized.get_pixel(x + 1, y).0[0]; + h <<= 1; + if l > r { + h |= 1; + } + } + } + h + } + + /// Hamming distance between two 64-bit hashes. + #[must_use] + pub fn hamming(a: u64, b: u64) -> u32 { + (a ^ b).count_ones() + } + + /// Convenience: wrap an Rgb crop into a grayscale buffer. + pub(crate) fn grayscale(img: &image::RgbImage) -> GrayImage { + imageops::grayscale(img) + } +} + +impl Evaluator for PixelDiffEvaluator { + fn evaluate(&self, predicate: &Predicate, ctx: &EvalCtx<'_>) -> Result { + let PredicateKind::PixelDiff { + region, + baseline, + threshold, + } = predicate + else { + return Err(PerceptionError::AssetDecode( + "pixel_diff evaluator received wrong predicate kind".into(), + )); + }; + + // v0 scope: only Region::Absolute is handled directly here. Window + // + RelativeTo are resolved by the runtime before calling us and + // passed in as Absolute. If we see a non-absolute, treat as false + // for v0 (the runtime should never hand us one). + let rect = match region { + vcli_core::Region::Absolute { rect } => *rect, + _ => { + return Ok(PredicateResult { + truthy: false, + match_data: None, + at: ctx.now_ms, + }); + } + }; + + // Hash key for state lookup: predicate-local (baseline + region). + let key_value = json!({ + "kind": "pixel_diff_state", + "baseline": baseline, + "rect": [rect.x, rect.y, rect.w, rect.h], + }); + let key_bytes = canonicalize(&key_value).map_err(|e| { + PerceptionError::AssetDecode(format!("canonicalize pixel_diff key: {e}")) + })?; + // SHA over canonical bytes via vcli_core::predicate_hash on the same value. + let state_key = vcli_core::predicate_hash(&key_value) + .map_err(|e| PerceptionError::AssetDecode(format!("hash pixel_diff key: {e}")))?; + let _ = key_bytes; // Retained for future diagnostics; canonicalize call asserts stability. + + // Compute current dHash of the region. + let current_crop = crop_rgb(ctx.frame, rect)?; + let current_gray = Self::grayscale(¤t_crop); + let current_hash = Self::dhash(¤t_gray); + + let prior = ctx.state.prior_snapshot(&state_key); + // Always record the current snapshot for the next tick. + ctx.state.record_snapshot( + state_key.clone(), + PriorSnapshot { + dhash: current_hash, + at_ms: ctx.now_ms, + }, + ); + + // No prior snapshot → first tick, not truthy (nothing to diff). + let Some(prev) = prior else { + return Ok(PredicateResult { + truthy: false, + match_data: None, + at: ctx.now_ms, + }); + }; + + // Fraction-of-64 Hamming distance vs threshold. + let distance = f64::from(Self::hamming(current_hash, prev.dhash)) / 64.0; + let truthy = distance >= *threshold; + 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 image::{Rgb, RgbImage}; + use vcli_core::geom::Rect; + use vcli_core::predicate::PredicateKind; + use vcli_core::{Frame, FrameFormat, Region}; + + use crate::cache::PredicateCache; + use crate::state::PerceptionState; + + /// 32×32 solid RGBA frame filled with `color`. + fn solid_frame(color: [u8; 3]) -> Frame { + let w = 32usize; + let h = 32usize; + let stride = w * 4; + let mut pixels = vec![0u8; stride * h]; + for y in 0..h { + for x in 0..w { + let off = y * stride + x * 4; + pixels[off] = color[0]; + pixels[off + 1] = color[1]; + pixels[off + 2] = color[2]; + pixels[off + 3] = 255; + } + } + // w and h are 32 — safe to cast to i32. + #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)] + let (wi, hi) = (w as i32, h as i32); + Frame::new( + FrameFormat::Rgba8, + Rect { + x: 0, + y: 0, + w: wi, + h: hi, + }, + stride, + Arc::from(pixels), + 0, + ) + } + + #[test] + fn dhash_and_hamming_on_identical_images_is_zero() { + let img = RgbImage::from_pixel(32, 32, Rgb([128, 128, 128])); + let g = PixelDiffEvaluator::grayscale(&img); + let h1 = PixelDiffEvaluator::dhash(&g); + let h2 = PixelDiffEvaluator::dhash(&g); + assert_eq!(PixelDiffEvaluator::hamming(h1, h2), 0); + } + + #[test] + fn dhash_differs_for_contrasting_images() { + let uniform = RgbImage::from_pixel(32, 32, Rgb([128, 128, 128])); + let mut striped = RgbImage::from_pixel(32, 32, Rgb([0, 0, 0])); + for y in 0..32u32 { + for x in (0..32u32).step_by(2) { + striped.put_pixel(x, y, Rgb([255, 255, 255])); + } + } + let h1 = PixelDiffEvaluator::dhash(&PixelDiffEvaluator::grayscale(&uniform)); + let h2 = PixelDiffEvaluator::dhash(&PixelDiffEvaluator::grayscale(&striped)); + // Striping is dramatic — should flip most bits. + assert!(PixelDiffEvaluator::hamming(h1, h2) > 16); + } + + #[test] + fn first_tick_not_truthy_but_records_snapshot() { + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = BTreeMap::new(); + let assets = BTreeMap::new(); + let frame = solid_frame([200, 50, 50]); + let p = PredicateKind::PixelDiff { + region: Region::Absolute { + rect: Rect { + x: 0, + y: 0, + w: 32, + h: 32, + }, + }, + baseline: "sha256:unused".into(), + threshold: 0.1, + }; + let ctx = EvalCtx { + frame: &frame, + now_ms: 1000, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: None, + }; + let r = PixelDiffEvaluator.evaluate(&p, &ctx).unwrap(); + assert!(!r.truthy); + } + + #[test] + fn same_scene_on_second_tick_is_not_truthy() { + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = BTreeMap::new(); + let assets = BTreeMap::new(); + let frame = solid_frame([200, 50, 50]); + let p = PredicateKind::PixelDiff { + region: Region::Absolute { + rect: Rect { + x: 0, + y: 0, + w: 32, + h: 32, + }, + }, + baseline: "sha256:unused".into(), + threshold: 0.1, + }; + // Tick 1: record snapshot. + let ctx1 = EvalCtx { + frame: &frame, + now_ms: 1000, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: None, + }; + let _ = PixelDiffEvaluator.evaluate(&p, &ctx1).unwrap(); + // Tick 2: same frame, no diff. + let ctx2 = EvalCtx { + frame: &frame, + now_ms: 1100, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: None, + }; + let r = PixelDiffEvaluator.evaluate(&p, &ctx2).unwrap(); + assert!(!r.truthy, "identical frames should not diff"); + } + + #[test] + fn changed_scene_on_second_tick_is_truthy() { + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = BTreeMap::new(); + let assets = BTreeMap::new(); + + // Two frames that will produce very different dHashes. + let frame_a = solid_frame([10, 10, 10]); + // For B, alternate rows of black/white — drastically different gray. + let mut frame_b_pixels = vec![0u8; 32 * 32 * 4]; + for y in 0..32usize { + for x in 0..32usize { + let off = y * 32 * 4 + x * 4; + let v = if (x + y) % 2 == 0 { 0u8 } else { 255u8 }; + frame_b_pixels[off] = v; + frame_b_pixels[off + 1] = v; + frame_b_pixels[off + 2] = v; + frame_b_pixels[off + 3] = 255; + } + } + let frame_b = Frame::new( + FrameFormat::Rgba8, + Rect { + x: 0, + y: 0, + w: 32, + h: 32, + }, + 32 * 4, + Arc::from(frame_b_pixels), + 0, + ); + + let p = PredicateKind::PixelDiff { + region: Region::Absolute { + rect: Rect { + x: 0, + y: 0, + w: 32, + h: 32, + }, + }, + baseline: "sha256:unused".into(), + threshold: 0.1, + }; + let ctx1 = EvalCtx { + frame: &frame_a, + now_ms: 1000, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: None, + }; + let _ = PixelDiffEvaluator.evaluate(&p, &ctx1).unwrap(); + let ctx2 = EvalCtx { + frame: &frame_b, + now_ms: 1100, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: None, + }; + let r = PixelDiffEvaluator.evaluate(&p, &ctx2).unwrap(); + assert!(r.truthy); + } +} diff --git a/crates/vcli-perception/src/state.rs b/crates/vcli-perception/src/state.rs new file mode 100644 index 0000000..7766304 --- /dev/null +++ b/crates/vcli-perception/src/state.rs @@ -0,0 +1,187 @@ +//! Cross-tick perception state. Distinct from `PredicateCache` which is +//! per-tick. `PerceptionState` carries: +//! +//! - prior-frame snapshots keyed by predicate hash, for `pixel_diff` +//! - first-true timestamps keyed by (`program_id`, `predicate_name`) pair, +//! for `elapsed_ms_since_true` +//! +//! Interior mutability only — every public method takes `&self`. + +use std::sync::Arc; + +use dashmap::DashMap; + +use vcli_core::clock::UnixMs; +use vcli_core::{PredicateHash, ProgramId}; + +/// A small perceptual summary of a region from a prior tick. The actual +/// representation is a `Vec` of 64-bit dHash bytes (8 bytes per +/// snapshot), not the raw pixels — we never store frames. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PriorSnapshot { + /// 64-bit dHash, big-endian. + pub dhash: u64, + /// Tick at which this snapshot was recorded. + pub at_ms: UnixMs, +} + +/// Key for per-program-predicate transition tracking. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ProgramPredKey { + /// Program that owns this transition timer. + pub program: ProgramId, + /// Predicate name within that program. + pub predicate: String, +} + +/// Cross-tick state shared across evaluators. +#[derive(Debug, Default)] +pub struct PerceptionState { + /// Prior-frame dHashes by predicate hash (global — same cache key as + /// the result cache, so cross-program dedup applies). + prior_snapshots: DashMap, + /// First-true timestamps for `elapsed_ms_since_true`. Program-local + /// per spec §"`elapsed_ms_since_true`". + first_true_at: DashMap, +} + +impl PerceptionState { + /// Fresh empty state. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Read the most recent snapshot for a `pixel_diff` predicate hash. + #[must_use] + pub fn prior_snapshot(&self, hash: &PredicateHash) -> Option { + self.prior_snapshots.get(hash).map(|s| s.clone()) + } + + /// Record a new snapshot. Overwrites any existing entry for this key. + pub fn record_snapshot(&self, hash: PredicateHash, snapshot: PriorSnapshot) { + self.prior_snapshots.insert(hash, snapshot); + } + + /// Read the first-true timestamp for a (program, predicate) pair, if + /// the child predicate has been truthy on a prior tick without a + /// falling edge in between. + #[must_use] + pub fn first_true_at(&self, key: &ProgramPredKey) -> Option { + self.first_true_at.get(key).map(|v| *v) + } + + /// Record the edge into `true`. If the child was already true, this + /// leaves the existing timestamp alone (caller should only call on a + /// rising edge). + pub fn set_first_true_at(&self, key: ProgramPredKey, at_ms: UnixMs) { + self.first_true_at.entry(key).or_insert(at_ms); + } + + /// Clear the first-true timestamp on a falling edge. + pub fn clear_first_true_at(&self, key: &ProgramPredKey) { + self.first_true_at.remove(key); + } + + /// Convenience for the runtime to package `Arc` into `EvalCtx`. + #[must_use] + pub fn shared() -> Arc { + Arc::new(Self::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use vcli_core::predicate_hash; + + fn hash(tag: &str) -> PredicateHash { + predicate_hash(&json!({"tag": tag})).unwrap() + } + + fn ppk(program_id_str: &str, name: &str) -> ProgramPredKey { + ProgramPredKey { + program: program_id_str.parse().unwrap(), + predicate: name.into(), + } + } + + #[test] + fn prior_snapshot_round_trip() { + let s = PerceptionState::new(); + let h = hash("a"); + assert!(s.prior_snapshot(&h).is_none()); + s.record_snapshot( + h.clone(), + PriorSnapshot { + dhash: 0xDEAD_BEEF, + at_ms: 1000, + }, + ); + assert_eq!( + s.prior_snapshot(&h), + Some(PriorSnapshot { + dhash: 0xDEAD_BEEF, + at_ms: 1000, + }) + ); + } + + #[test] + fn record_snapshot_overwrites() { + let s = PerceptionState::new(); + let h = hash("a"); + s.record_snapshot( + h.clone(), + PriorSnapshot { + dhash: 1, + at_ms: 100, + }, + ); + s.record_snapshot( + h.clone(), + PriorSnapshot { + dhash: 2, + at_ms: 200, + }, + ); + assert_eq!(s.prior_snapshot(&h).unwrap().dhash, 2); + } + + #[test] + fn first_true_set_then_read() { + let s = PerceptionState::new(); + let k = ppk("00000000-0000-4000-8000-000000000001", "p"); + assert!(s.first_true_at(&k).is_none()); + s.set_first_true_at(k.clone(), 5000); + assert_eq!(s.first_true_at(&k), Some(5000)); + } + + #[test] + fn first_true_set_preserves_earliest() { + let s = PerceptionState::new(); + let k = ppk("00000000-0000-4000-8000-000000000001", "p"); + s.set_first_true_at(k.clone(), 5000); + s.set_first_true_at(k.clone(), 9000); + assert_eq!(s.first_true_at(&k), Some(5000)); + } + + #[test] + fn first_true_cleared_on_falling_edge() { + let s = PerceptionState::new(); + let k = ppk("00000000-0000-4000-8000-000000000001", "p"); + s.set_first_true_at(k.clone(), 5000); + s.clear_first_true_at(&k); + assert!(s.first_true_at(&k).is_none()); + } + + #[test] + fn different_programs_do_not_share_first_true_timestamps() { + let s = PerceptionState::new(); + let a = ppk("00000000-0000-4000-8000-000000000001", "p"); + let b = ppk("00000000-0000-4000-8000-000000000002", "p"); + s.set_first_true_at(a.clone(), 1000); + assert!(s.first_true_at(&b).is_none()); + } +} diff --git a/crates/vcli-perception/src/template.rs b/crates/vcli-perception/src/template.rs new file mode 100644 index 0000000..666d801 --- /dev/null +++ b/crates/vcli-perception/src/template.rs @@ -0,0 +1,423 @@ +//! `TemplateEvaluator` — Tier 2. NCC template matching via +//! `imageproc::template_matching::match_template` with +//! `MatchTemplateMethod::CrossCorrelationNormalized`. +//! +//! Spec §"Template matching": `imageproc` NCC, region-scoped by default +//! (the runtime passes the region as `Region::Absolute` after resolving +//! `window` / `relative_to`). Confidence is an inclusive threshold in +//! [0, 1]. v0 **does not** implement pyramid search — Decision 4.2 +//! defers that to the runtime/submit pipeline (it only affects how +//! templates are stored, not the evaluator's interface). + +use image::{imageops, GrayImage}; +use imageproc::template_matching::{find_extremes, match_template, MatchTemplateMethod}; + +use vcli_core::geom::Rect; +use vcli_core::predicate::{Confidence, PredicateKind}; +use vcli_core::{MatchData, Predicate, PredicateResult, Region}; + +use crate::error::{PerceptionError, Result}; +use crate::evaluator::{EvalCtx, Evaluator}; +use crate::frame_view::{crop_rgb, frame_to_rgb}; + +/// Evaluator for `PredicateKind::Template`. +#[derive(Debug, Default)] +pub struct TemplateEvaluator; + +impl TemplateEvaluator { + /// Decode a PNG byte-slice into a grayscale image. Used by the + /// evaluator to load the asset the daemon materialized into + /// `EvalCtx::assets`. + /// + /// # Errors + /// + /// Returns `AssetDecode` if `image::load_from_memory` fails. + pub fn decode_gray(bytes: &[u8]) -> Result { + let dyn_img = image::load_from_memory(bytes) + .map_err(|e| PerceptionError::AssetDecode(e.to_string()))?; + Ok(dyn_img.to_luma8()) + } +} + +impl Evaluator for TemplateEvaluator { + fn evaluate(&self, predicate: &Predicate, ctx: &EvalCtx<'_>) -> Result { + let PredicateKind::Template { + image: image_ref, + confidence, + region, + throttle_ms: _, + } = predicate + else { + return Err(PerceptionError::AssetDecode( + "template evaluator got wrong kind".into(), + )); + }; + + // Resolve the asset bytes. Daemon strips the "sha256:" prefix + // before handing bytes to us, so the key is the hex digest. + let key = image_ref.strip_prefix("sha256:").unwrap_or(image_ref); + let bytes = ctx + .assets + .get(key) + .ok_or_else(|| PerceptionError::AssetNotMaterialized(image_ref.clone()))?; + let template_gray = Self::decode_gray(bytes)?; + + // Select the haystack: either the full frame or a cropped region. + let (haystack_rgb, region_origin) = match region { + Region::Absolute { rect } => { + let crop = crop_rgb(ctx.frame, *rect)?; + ( + crop, + ( + rect.x.max(ctx.frame.bounds.x), + rect.y.max(ctx.frame.bounds.y), + ), + ) + } + // Window / RelativeTo should have been resolved to Absolute + // by the runtime before this evaluator is called. Treat as + // the whole frame as a graceful fallback. + _ => ( + frame_to_rgb(ctx.frame)?, + (ctx.frame.bounds.x, ctx.frame.bounds.y), + ), + }; + let haystack_gray = imageops::grayscale(&haystack_rgb); + + // Haystack must be at least as big as the template. + if haystack_gray.width() < template_gray.width() + || haystack_gray.height() < template_gray.height() + { + return Ok(PredicateResult { + truthy: false, + match_data: None, + at: ctx.now_ms, + }); + } + + // Normalized cross-correlation. Returns a response map where + // higher values = better match; max is 1.0 for a perfect match. + let result_map = match_template( + &haystack_gray, + &template_gray, + MatchTemplateMethod::CrossCorrelationNormalized, + ); + let extremes = find_extremes(&result_map); + let (best_val, best_loc) = (extremes.max_value, extremes.max_value_location); + + // `best_val` is in [0, 1] for normalized cross-correlation. + let best_val_f64 = f64::from(best_val); + let threshold = confidence.0; + + if best_val_f64 < threshold { + return Ok(PredicateResult { + truthy: false, + match_data: None, + at: ctx.now_ms, + }); + } + + // Translate the match location from haystack-local to absolute + // screen coordinates. best_loc and template dimensions are u32 from + // imageproc; practical frame dimensions fit safely in i32. + #[allow(clippy::cast_possible_wrap)] + let bbox = Rect { + x: region_origin.0 + best_loc.0 as i32, + y: region_origin.1 + best_loc.1 as i32, + w: template_gray.width() as i32, + h: template_gray.height() as i32, + }; + Ok(PredicateResult { + truthy: true, + match_data: Some(MatchData { + bbox, + confidence: Confidence(best_val_f64), + }), + at: ctx.now_ms, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::sync::Arc; + + use image::{ImageBuffer, Luma, Rgb}; + + use vcli_core::geom::Rect; + use vcli_core::{Frame, FrameFormat, Region}; + + use crate::cache::PredicateCache; + use crate::state::PerceptionState; + + /// Build a 64×64 frame that contains the `template_png_varied_16x8` + /// pattern at position (20, 20): a white background with a white 16×8 + /// patch at (20,20) that has a black 8×4 inner block at (24, 22). + /// This matches the template created by `template_png_varied_16x8`. + fn scene_with_box() -> Frame { + let w = 64usize; + let h = 64usize; + let stride = w * 4; + // Start all white. + let mut pixels = vec![255u8; stride * h]; + // Place the black inner block at absolute (24, 22) = (20+4, 20+2) + for y in 22..26usize { + for x in 24..32usize { + let off = y * stride + x * 4; + pixels[off] = 0; + pixels[off + 1] = 0; + pixels[off + 2] = 0; + pixels[off + 3] = 255; + } + } + // w=64, h=64 — safe to cast to i32. + #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)] + let (wi, hi) = (w as i32, h as i32); + Frame::new( + FrameFormat::Rgba8, + Rect { + x: 0, + y: 0, + w: wi, + h: hi, + }, + stride, + Arc::from(pixels), + 0, + ) + } + + /// Build a PNG byte-stream for a 16×8 template that has variation: + /// a black center (8×4) surrounded by a white border. This ensures + /// non-zero variance so NCC gives a meaningful score. + /// Note: NCC with a constant (all-black) template returns 0 due to + /// zero variance — templates must have variation to work with NCC. + fn template_png_varied_16x8() -> Vec { + let mut img: ImageBuffer, Vec> = + ImageBuffer::from_pixel(16, 8, Rgb([255, 255, 255])); + // Fill inner 8×4 region with black to give variation. + for y in 2..6u32 { + for x in 4..12u32 { + img.put_pixel(x, y, Rgb([0, 0, 0])); + } + } + let mut bytes: Vec = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .unwrap(); + bytes + } + + fn template_png_white_16x8() -> Vec { + let img: ImageBuffer, Vec> = + ImageBuffer::from_pixel(16, 8, Rgb([255, 255, 255])); + let mut bytes: Vec = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .unwrap(); + bytes + } + + #[test] + fn decode_gray_parses_valid_png() { + let png = template_png_varied_16x8(); + let g = TemplateEvaluator::decode_gray(&png).unwrap(); + assert_eq!(g.width(), 16); + assert_eq!(g.height(), 8); + } + + #[test] + fn decode_gray_errors_on_garbage() { + let r = TemplateEvaluator::decode_gray(b"not a png"); + assert!(matches!(r, Err(PerceptionError::AssetDecode(_)))); + } + + #[test] + fn template_match_found_in_scene_reports_correct_bbox() { + // Note: NCC (CrossCorrelationNormalized) requires the template to have + // non-zero variance. A constant (all-black) template yields NCC=0 + // everywhere due to zero variance. We use `template_png_varied_16x8` + // (white border, black inner block) which has variation, giving NCC>0. + let frame = scene_with_box(); + let template_bytes = template_png_varied_16x8(); + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = BTreeMap::new(); + let mut assets = BTreeMap::new(); + assets.insert("pattern".into(), template_bytes); + let p = PredicateKind::Template { + image: "sha256:pattern".into(), + confidence: Confidence(0.5), + region: Region::Absolute { + rect: Rect { + x: 0, + y: 0, + w: 64, + h: 64, + }, + }, + throttle_ms: 200, + }; + let ctx = EvalCtx { + frame: &frame, + now_ms: 42, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: None, + }; + let r = TemplateEvaluator.evaluate(&p, &ctx).unwrap(); + assert!(r.truthy, "should find the varied template in the scene"); + let m = r.match_data.expect("match_data populated on truthy"); + assert_eq!(m.bbox.w, 16); + assert_eq!(m.bbox.h, 8); + // Template placed so the black inner block lines up at (24,22)=origin+(4,2) + assert_eq!(m.bbox.x, 20); + assert_eq!(m.bbox.y, 20); + assert!(m.confidence.0 >= 0.5); + } + + #[test] + fn template_missing_in_scene_is_falsy() { + let frame = scene_with_box(); // black-box-on-white + let template_bytes = template_png_white_16x8(); // all-white template never correlates high + // Actually: NCC of all-white against a region with all-white pixels + // can return 1.0 trivially, BUT find_extremes on constant images + // returns NaN due to zero variance — imageproc guards this by + // returning 0. Prefer using confidence 0.99 + a scene whose + // whitespace regions still won't exceed it. Simpler: use a + // distinctive template instead. Use a 32×32 gradient template + // that cannot be found in a 64x64 solid scene. + let _ = template_bytes; // Unused: we build a different template below. + + let mut grad: ImageBuffer, Vec> = ImageBuffer::new(32, 32); + for y in 0..32u32 { + for x in 0..32u32 { + grad.put_pixel( + x, + y, + Rgb([((x * 8) % 255) as u8, ((y * 8) % 255) as u8, 128]), + ); + } + } + let mut grad_bytes: Vec = Vec::new(); + grad.write_to( + &mut std::io::Cursor::new(&mut grad_bytes), + image::ImageFormat::Png, + ) + .unwrap(); + + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = BTreeMap::new(); + let mut assets = BTreeMap::new(); + assets.insert("grad".into(), grad_bytes); + let p = PredicateKind::Template { + image: "sha256:grad".into(), + confidence: Confidence(0.99), + region: Region::Absolute { + rect: Rect { + x: 0, + y: 0, + w: 64, + h: 64, + }, + }, + throttle_ms: 200, + }; + let ctx = EvalCtx { + frame: &frame, + now_ms: 42, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: None, + }; + let r = TemplateEvaluator.evaluate(&p, &ctx).unwrap(); + assert!(!r.truthy); + assert!(r.match_data.is_none()); + } + + #[test] + fn template_larger_than_haystack_is_falsy() { + let frame = scene_with_box(); + // Build a template larger than the search region. + let big: ImageBuffer, Vec> = ImageBuffer::from_pixel(200, 200, Luma([0])); + let mut bytes: Vec = Vec::new(); + big.write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .unwrap(); + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = BTreeMap::new(); + let mut assets = BTreeMap::new(); + assets.insert("big".into(), bytes); + let p = PredicateKind::Template { + image: "sha256:big".into(), + confidence: Confidence(0.5), + region: Region::Absolute { + rect: Rect { + x: 0, + y: 0, + w: 64, + h: 64, + }, + }, + throttle_ms: 200, + }; + let ctx = EvalCtx { + frame: &frame, + now_ms: 42, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: None, + }; + let r = TemplateEvaluator.evaluate(&p, &ctx).unwrap(); + assert!(!r.truthy); + } + + #[test] + fn missing_asset_errors() { + let frame = scene_with_box(); + let cache = PredicateCache::new(); + let state = PerceptionState::new(); + let preds = BTreeMap::new(); + let assets = BTreeMap::new(); // no "blackbox" entry + let p = PredicateKind::Template { + image: "sha256:blackbox".into(), + confidence: Confidence(0.9), + region: Region::Absolute { + rect: Rect { + x: 0, + y: 0, + w: 64, + h: 64, + }, + }, + throttle_ms: 200, + }; + let ctx = EvalCtx { + frame: &frame, + now_ms: 42, + cache: &cache, + state: &state, + predicates: &preds, + assets: &assets, + program: None, + }; + let r = TemplateEvaluator.evaluate(&p, &ctx); + assert!(matches!(r, Err(PerceptionError::AssetNotMaterialized(_)))); + } +} diff --git a/crates/vcli-perception/tests/cache_and_state.rs b/crates/vcli-perception/tests/cache_and_state.rs new file mode 100644 index 0000000..92b10cd --- /dev/null +++ b/crates/vcli-perception/tests/cache_and_state.rs @@ -0,0 +1,115 @@ +//! Integration tests that exercise Perception as a whole: +//! +//! - Multi-program cache dedup for identical predicate canonical forms +//! - Per-tick cache wipe via `clear()` +//! - Cross-tick PerceptionState preservation for elapsed_ms_since_true + +use std::collections::BTreeMap; +use std::sync::Arc; + +use vcli_core::geom::{Point, Rect}; +use vcli_core::predicate::{PredicateKind, Rgb}; +use vcli_core::{Frame, FrameFormat, Predicate, ProgramId}; + +use vcli_perception::Perception; + +fn red_frame() -> Frame { + Frame::new( + FrameFormat::Rgba8, + Rect { + x: 0, + y: 0, + w: 1, + h: 1, + }, + 4, + Arc::from(vec![255u8, 0, 0, 255]), + 0, + ) +} + +fn color_pred(r: u8, g: u8, b: u8) -> Predicate { + PredicateKind::ColorAt { + point: Point { x: 0, y: 0 }, + rgb: Rgb([r, g, b]), + tolerance: 0, + } +} + +#[test] +fn cache_dedups_across_distinct_program_predicate_names() { + let p = Perception::new(); + let mut preds = BTreeMap::new(); + // Two programs, each with their own local name, but identical canonical form. + preds.insert("prog_a_red".into(), color_pred(255, 0, 0)); + preds.insert("prog_b_red".into(), color_pred(255, 0, 0)); + let assets = BTreeMap::new(); + let frame = red_frame(); + let _ = p + .evaluate_named("prog_a_red", &preds, &frame, 100, &assets, None) + .unwrap(); + let _ = p + .evaluate_named("prog_b_red", &preds, &frame, 200, &assets, None) + .unwrap(); + assert_eq!(p.cache().len(), 1, "identical canonical predicates dedupe"); +} + +#[test] +fn per_tick_clear_invalidates_results() { + let p = Perception::new(); + let mut preds = BTreeMap::new(); + preds.insert("red".into(), color_pred(255, 0, 0)); + let assets = BTreeMap::new(); + let frame = red_frame(); + let r1 = p + .evaluate_named("red", &preds, &frame, 100, &assets, None) + .unwrap(); + p.clear(); + let r2 = p + .evaluate_named("red", &preds, &frame, 200, &assets, None) + .unwrap(); + // Without cache clear, r2.at would equal r1.at (100). With it, r2 is + // freshly evaluated at 200. + assert_eq!(r1.at, 100); + assert_eq!(r2.at, 200); +} + +#[test] +fn elapsed_ms_since_true_persists_across_tick_clears() { + let p = Perception::new(); + let pid = ProgramId::new(); + let mut preds = BTreeMap::new(); + preds.insert("red".into(), color_pred(255, 0, 0)); + preds.insert( + "stable_500".into(), + PredicateKind::ElapsedMsSinceTrue { + predicate: "red".into(), + ms: 500, + }, + ); + let assets = BTreeMap::new(); + let frame = red_frame(); + + // Tick 1 at t=1000: child true, 0ms elapsed → stable_500 is false. + let r = p + .evaluate_named("stable_500", &preds, &frame, 1000, &assets, Some(pid)) + .unwrap(); + assert!(!r.truthy); + + // End of tick — runtime clears the cache. + p.clear(); + + // Tick 2 at t=1499: 499ms elapsed, still false. + let r = p + .evaluate_named("stable_500", &preds, &frame, 1499, &assets, Some(pid)) + .unwrap(); + assert!(!r.truthy); + + p.clear(); + + // Tick 3 at t=1500: exactly 500ms — truthy. + let r = p + .evaluate_named("stable_500", &preds, &frame, 1500, &assets, Some(pid)) + .unwrap(); + assert!(r.truthy); +} diff --git a/crates/vcli-perception/tests/fixtures/README.md b/crates/vcli-perception/tests/fixtures/README.md new file mode 100644 index 0000000..8a57a74 --- /dev/null +++ b/crates/vcli-perception/tests/fixtures/README.md @@ -0,0 +1,12 @@ +# Test fixtures for vcli-perception + +All PNG fixtures used by tests in this crate are generated **programmatically** +in the test code — we deliberately do NOT check in binary assets for unit +tests. See: + +- `src/template.rs` — `template_png_varied_16x8()` and `template_png_white_16x8()` + build tiny PNGs at runtime via the `image` crate. +- `src/pixel_diff.rs` — `solid_frame()` builds in-memory RGBA frames. + +Integration fixtures (the YT skip-button PNG used in e2e demos) live under +`/assets/fixtures/` at the workspace root, not here.