diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..072d1d8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: 2026 PullsarDev +# SPDX-License-Identifier: Apache-2.0 + +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + build-and-test: + name: ${{ matrix.name }} Debug (${{ matrix.checks }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + compiler: [msvc, clang, gcc] + checks: [default, constrained] + include: + - compiler: msvc + name: Windows MSVC + os: windows-latest + cmake_args: "" + - compiler: clang + name: Linux Clang + os: ubuntu-latest + cmake_args: -DCMAKE_CXX_COMPILER=clang++ + - compiler: gcc + name: Linux GCC + os: ubuntu-latest + cmake_args: -DCMAKE_CXX_COMPILER=g++ + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Configure + shell: bash + run: > + cmake -S . -B build + -DCMAKE_BUILD_TYPE=Debug + -DINTEREST_WARNINGS_AS_ERRORS=ON + -DINTEREST_CONSTRAINED_BUILD=${{ matrix.checks == 'constrained' && 'ON' || 'OFF' }} + ${{ matrix.cmake_args }} + - name: Build + run: cmake --build build --config Debug --parallel 2 + - name: Test + run: ctest --test-dir build -C Debug --output-on-failure --no-tests=error + + optimized: + name: ${{ matrix.name }} Release + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - name: Windows MSVC + os: windows-latest + cmake_args: "" + - name: Linux Clang + os: ubuntu-latest + cmake_args: -DCMAKE_CXX_COMPILER=clang++ + - name: Linux GCC + os: ubuntu-latest + cmake_args: -DCMAKE_CXX_COMPILER=g++ + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Configure + run: > + cmake -S . -B build-release + -DCMAKE_BUILD_TYPE=Release + -DINTEREST_WARNINGS_AS_ERRORS=ON + ${{ matrix.cmake_args }} + - name: Build + run: cmake --build build-release --config Release --parallel 2 + - name: Test + run: ctest --test-dir build-release -C Release --output-on-failure --no-tests=error + - name: Build from a parent CMake project + run: > + cmake -S examples/consumer -B build-consumer + -DCMAKE_BUILD_TYPE=Release + ${{ matrix.cmake_args }} + - name: Build consumer + run: cmake --build build-consumer --config Release --parallel 2 + - name: Test consumer + run: ctest --test-dir build-consumer -C Release --output-on-failure --no-tests=error + + sanitizers: + name: Linux Clang ASan + UBSan + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + ASAN_OPTIONS: detect_leaks=1 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Configure + run: > + cmake -S . -B build-sanitizers + -DCMAKE_CXX_COMPILER=clang++ + -DCMAKE_BUILD_TYPE=Debug + -DINTEREST_WARNINGS_AS_ERRORS=ON + -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer" + - name: Build + run: cmake --build build-sanitizers --parallel 2 + - name: Test + run: ctest --test-dir build-sanitizers --output-on-failure --no-tests=error diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..3d47463 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: 2026 PullsarDev +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.20) + +project(InterestEngine VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_EXTENSIONS OFF) + +set(interest_top_level OFF) +if(CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR) + set(interest_top_level ON) +endif() + +option(INTEREST_BUILD_TESTS "Build the Interest Engine tests" ${interest_top_level}) +option(INTEREST_BUILD_BENCHMARKS "Build the Step benchmark harness" ${interest_top_level}) +option(INTEREST_BUILD_EXAMPLES "Build the basic API example" ${interest_top_level}) +option(INTEREST_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) +option(INTEREST_CONSTRAINED_BUILD "Compile without exceptions or RTTI, with shadow warnings" OFF) + +if(INTEREST_CONSTRAINED_BUILD AND MSVC) + # CMake's default MSVC flags enable exceptions; remove them so /EHs-c- is not overridden. + string(REPLACE "/EHsc" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") +endif() + +# Project-wide compile settings shared by the kernel and its tests. +add_library(interest_build_settings INTERFACE) +target_compile_features(interest_build_settings INTERFACE cxx_std_20) + +if(MSVC) + target_compile_options(interest_build_settings INTERFACE + /W4 /permissive- /utf-8 /Zc:__cplusplus /Zc:preprocessor) + if(INTEREST_WARNINGS_AS_ERRORS) + target_compile_options(interest_build_settings INTERFACE /WX) + endif() + if(INTEREST_CONSTRAINED_BUILD) + # C4456-C4459: declaration hides previous local/parameter/member/global. + target_compile_options(interest_build_settings INTERFACE + /EHs-c- /GR- /we4456 /we4457 /we4458 /we4459) + target_compile_definitions(interest_build_settings INTERFACE _HAS_EXCEPTIONS=0) + endif() +else() + target_compile_options(interest_build_settings INTERFACE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wdouble-promotion) + if(INTEREST_WARNINGS_AS_ERRORS) + target_compile_options(interest_build_settings INTERFACE -Werror) + endif() + if(INTEREST_CONSTRAINED_BUILD) + target_compile_options(interest_build_settings INTERFACE + -fno-exceptions -fno-rtti -Wshadow) + endif() +endif() + +add_library(interest_core STATIC + src/step.cpp + src/version.cpp) +add_library(InterestEngine::Core ALIAS interest_core) +target_include_directories(interest_core PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_link_libraries(interest_core PRIVATE interest_build_settings) +target_compile_features(interest_core PUBLIC cxx_std_20) + +if(INTEREST_BUILD_TESTS) + enable_testing() + + add_executable(interest_smoke_test tests/smoke_test.cpp) + target_link_libraries(interest_smoke_test PRIVATE interest_core interest_build_settings) + if(INTEREST_CONSTRAINED_BUILD) + # Lets the smoke test confirm the constraints were actually applied. + target_compile_definitions(interest_smoke_test PRIVATE INTEREST_EXPECT_CONSTRAINED_BUILD=1) + endif() + + add_test(NAME interest_smoke_test COMMAND interest_smoke_test) + + foreach(suite IN ITEMS scenario scoring allocation c07c temporal baseline rate observers novelty retention c18 property) + add_executable(interest_${suite}_test tests/step_${suite}_test.cpp) + target_link_libraries(interest_${suite}_test PRIVATE interest_core interest_build_settings) + add_test(NAME interest_${suite}_test COMMAND interest_${suite}_test) + endforeach() + +endif() + +if(INTEREST_BUILD_BENCHMARKS) + # CTest checks harness invariants; use optimized manual runs for timing evidence. + add_executable(interest_step_benchmark benchmarks/step_benchmark.cpp) + target_include_directories(interest_step_benchmark PRIVATE tests) + target_link_libraries(interest_step_benchmark PRIVATE interest_core interest_build_settings) + if(INTEREST_BUILD_TESTS) + add_test(NAME interest_benchmark_smoke COMMAND interest_step_benchmark --smoke) + endif() +endif() + +if(INTEREST_BUILD_EXAMPLES) + add_executable(interest_basic_example examples/basic.cpp) + target_link_libraries(interest_basic_example PRIVATE InterestEngine::Core interest_build_settings) + if(INTEREST_BUILD_TESTS) + add_test(NAME interest_basic_example COMMAND interest_basic_example) + endif() +endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..26771c0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing + +Use a focused pull request that describes the problem, the resulting behavior and +the checks actually run. Include a minimal reproducible scenario for a bug report: +snapshots, profile and context values, explicit times, previous state, status and +decision/trace. Remove private game data before submitting. + +The production library uses C++20 and the standard library only. Preserve explicit +time, caller ownership, allocation-free prepared steps, validation precedence and +deterministic candidate ordering. Changes to numerical behavior or public contracts +need deliberate review and regression evidence. Keep host integration outside the +kernel. + +Build with warnings treated as errors and run CTest in Debug and Release. Follow +the README commands; also check `INTEREST_CONSTRAINED_BUILD=ON` where practical. +The CI matrix adds GCC, Clang, MSVC and Clang address/undefined-behavior sanitizers. +Report unavailable configurations honestly. + +Tests use dependency-free checks that remain active under `NDEBUG`. The suites cover: + +| Area | Files in `tests/` | +|---|---| +| Version and compiler constraints | `smoke_test.cpp` | +| Snapshots, rejection paths, gates and formulas | `step_scenario_test.cpp`, `step_scoring_test.cpp` | +| Allocation and capacity contracts | `step_allocation_test.cpp` | +| Alternative formula controls | `step_c07c_test.cpp` | +| Hysteresis, dwell, switching and time extremes | `step_temporal_test.cpp` | +| Stateless baseline, update rates, observer variation | `step_baseline_test.cpp`, `step_rate_test.cpp`, `step_observers_test.cpp` | +| Novelty, saturation and sensitivity | `step_novelty_test.cpp`, `step_retention_test.cpp` | +| Sustained churn, exact replay, permutations and failed-step atomicity | `step_c18_test.cpp` | +| Geometry transforms, finite extremes and time translation/decay | `step_property_test.cpp` | + +In test output, **B1** is stateless selection of the leading candidate at or above +the acquisition threshold. **B2** uses the same contextual scores with the kernel's +temporal policy; novelty is disabled for these baseline comparisons. B1 is read +from the Step trace so the comparison uses identical scores. Ablations change one +temporal mechanism through profile parameters. + +Labels such as `C07c`, `C12` and `C18` are stable scenario identifiers: preference +formula comparison, update-rate comparison and sustained churn, respectively. +Letter suffixes distinguish variants. The test functions and nearby comments +describe each scenario; these labels are not public API concepts. + +The benchmark also checks paired inputs, replay, trace independence and allocation +counts in smoke mode. See [benchmark methodology](docs/benchmarks.md) before making +performance claims. Do not use CI smoke timings as performance results. + +When editing the README example, update `examples/basic.cpp` identically and run +`interest_basic_example`. The parent-project fixture in `examples/consumer` checks +the exported CMake target and that utilities default off when embedded. + +By submitting a contribution, you agree to license it under Apache-2.0, unless +explicitly stated otherwise. Preserve third-party attribution and identify the +origin and license of any proposed external material. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b4de97 --- /dev/null +++ b/README.md @@ -0,0 +1,292 @@ +# Interest Engine + +Interest Engine is a standalone C++20 library that selects one attention target for +a game NPC. Give it the stimuli your game has already perceived, an observer's +context and preferences, and the previous selection. It returns a target ID or no +target, together with a reason. Hysteresis and optional minimum dwell keep small +score fluctuations from causing constant switching; timestamped events can briefly +raise a stimulus's priority. + +The kernel handles selection. Your game supplies perception, stable IDs, time and +snapshots, then resolves the chosen ID into gameplay or presentation. There is no +engine dependency, scene query, object binding, action planner, gaze controller, +animation system, learning model or network service. + +**Version 0.1.0:** an early source library with substantive synthetic regression +coverage. API, ABI and tuning compatibility may change before 1.0; there is no +serialized-state format or cross-platform bit-exact simulation guarantee. + +## Engineering characteristics + +- Standard-library-only production C++20; two implementation files and two headers. +- Explicit host time and caller-owned state; no hidden clock, RNG or global kernel state. +- Prepared `Step()` calls allocate nothing. Setup through `Reserve()` may allocate + and, with exceptions enabled, throw `std::bad_alloc`. +- Errors at the step level leave memory, decision, scratch and trace untouched. + Candidate-level errors reject that candidate, including every occurrence of a + duplicate ID. Invalid numeric inputs are rejected rather than silently repaired. +- Stable ID tie-breaking, candidate-order-independent selection and matching + decisions with tracing enabled or disabled, covered by regression tests. +- Optional traces expose the values used by the actual scoring and policy path. + +Replay is deterministic within a tested build/platform for the same inputs, times, +profile and initial memory. Floating-point math and library implementations can +differ across compilers, platforms and build flags. Avoid fast-math flags if you +depend on the validation and numerical contracts. + +## Inputs and decisions + +| Input | Purpose | +|---|---| +| `ObserverSnapshot` | Position, reference forward direction and context index; observer ID is bookkeeping only | +| `CandidateSnapshot` | Stable nonzero ID, spatial data, attraction, salience, perception strength, tags and optional event | +| `AttentionProfile` | Scoring weights, spatial gates, thresholds, margins, dwell and novelty parameters; borrows context storage | +| `ContextPreference` | Required/excluded tags, tag affinities and fallback affinity | +| `ObserverMemory` | Previous selection and temporal state; one persistent instance per observer | +| `StepScratch`, optional `DecisionTrace` | Caller-reserved reusable workspace and diagnostics | +| `TimeMs now` | Explicit signed milliseconds, non-decreasing between successful calls for an observer | + +Supported stimulus geometry: + +| Kind | Gates | +|---|---| +| `Point` | World position, distance cutoff/falloff and angle | +| `Bearing` | World direction and angle; no distance gate | +| `NonSpatial` | No distance or angle penalty | + +Positions and distances use consistent host units. Forward and bearing directions +may have any finite nonzero length. `referenceForward` must be a host-owned body, +travel or attention baseline: feeding back the selected target's head/eye direction +would reinforce its own angle score. + +For an eligible candidate, the kernel takes the maximum configured affinity among +its matching tag bits, or the fallback when none match. Attraction, salience and +affinity form a weighted mean. Distance, angle and perception strength multiply +that preference into a raw score. There are 64 host-defined tag bits and up to 256 +addressable context indices. Each step validates every supplied context entry. + +Optional novelty adds `beta * gate * eventStrength * 2^(-age / halfLife)` to the raw +score, saturating the effective score at 1. An event is the latest host-authored +timestamp/strength pair, resubmitted unchanged while relevant. Re-stamping an old +event every update continually renews it. Zero strength means absent; positive +strength requires a timestamp no later than `now`, even when beta is zero. + +Selection acquires at or above `acquireThreshold`. An eligible incumbent is protected by +the release threshold, positive switch margin and optional minimum dwell. A large +enough challenger can break dwell through `overrideMargin`. An absent or ineligible +incumbent is released or replaced immediately. Exact score ties use the lower ID +for acquisition and never displace an incumbent. Dwell is observed only at steps, +so response timing depends on the host update interval. + +`Decision` reports `Acquire`, `Keep`, `Switch`, `Clear` or `None`, the previous and +selected IDs, scores and a `DecisionReason`. `kNoCandidate` is zero. Check +`StepStatus` before consuming a decision: an error leaves the old output in place. +The validation order is profile, observer, time, then capacity. See +[`attention.hpp`](include/interest/attention.hpp) for exact parameter ranges, +rejection precedence and ownership contracts. + +## Build and test + +Requirements: CMake 3.20 or newer, a C++20 compiler and its standard library. The +validation matrix covers Windows x64 MSVC (Visual Studio 2022) and Linux x86-64 +GCC/Clang, in Debug and Release, plus Clang ASan/UBSan. The recorded performance +baseline uses MSVC 19.38. This is a tested matrix, not a promise that every compiler +version or platform is supported. macOS, ARM and other architectures are unverified. + +From a compiler-enabled shell: + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DINTEREST_WARNINGS_AS_ERRORS=ON +cmake --build build --config Release --parallel 2 +ctest --test-dir build -C Release --output-on-failure --no-tests=error +``` + +For an explicit Windows Visual Studio generator, configure with: + +```sh +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 -DINTEREST_WARNINGS_AS_ERRORS=ON +``` + +Use the same build/test commands above. `CMAKE_BUILD_TYPE` controls single-config +generators; Visual Studio uses `--config` / `-C`. Repeat with Debug to exercise that +configuration. Tests use checks that remain active in optimized builds. + +| CMake option | Default when standalone | Purpose | +|---|---|---| +| `INTEREST_BUILD_TESTS` | `ON` | Regression tests and CTest registration | +| `INTEREST_BUILD_BENCHMARKS` | `ON` | Benchmark executable; smoke test when tests are enabled | +| `INTEREST_BUILD_EXAMPLES` | `ON` | Build the example and test it when tests are enabled | +| `INTEREST_WARNINGS_AS_ERRORS` | `OFF` | Project warnings become errors | +| `INTEREST_CONSTRAINED_BUILD` | `OFF` | Generic no-exceptions/no-RTTI build check with shadow warnings | + +Tests, benchmarks and examples default to `OFF` under `add_subdirectory()`. +The constrained configuration verifies compilation with those flags; it does not +remove `Reserve()`'s need for successful setup allocation. + +## Integrate with CMake + +Place a source checkout under your project, for example at `external/InterestEngine`: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(MyGame LANGUAGES CXX) + +add_subdirectory(external/InterestEngine) +add_executable(my_game main.cpp) +target_link_libraries(my_game PRIVATE InterestEngine::Core) +``` + +`InterestEngine::Core` aliases the static `interest_core` target and supplies its +include path and C++20 requirement. Warning policy stays on this project's targets. +There is no package manager, download step or installed `find_package()` package. +The executable example below is also built by the parent-project fixture in +[`examples/consumer`](examples/consumer/CMakeLists.txt). + +## Example + +This complete program is [`examples/basic.cpp`](examples/basic.cpp), built as +`interest_basic_example` and executed by CTest. The zero-initialized profile is +deliberately invalid until the required ranges, thresholds, margins and contexts +are supplied. Context storage outlives both steps; memory persists across them; +scratch and optional trace capacity are reserved before stepping. + +```cpp +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +#include "interest/attention.hpp" + +#include +#include + +int main() +{ + // The profile borrows this storage. Keep it alive through every Step call. + std::array contexts{}; + contexts[0].affinityTags = 1; + contexts[0].tagAffinity[0] = 0.8f; + + interest::AttentionProfile profile{}; + profile.contexts = contexts; + profile.fullRange = 10.f; + profile.maxRange = 30.f; + profile.acquireThreshold = 0.5f; + profile.releaseThreshold = 0.35f; + profile.switchMargin = 0.1f; + profile.overrideMargin = 0.3f; + profile.minDwellMs = 400; + profile.noveltyBeta = 0.25f; + profile.noveltyHalfLifeMs = 1000; + if (interest::ValidateProfile(profile) != interest::StepStatus::Ok) { + return 1; + } + + interest::ObserverMemory memory{}; // one per observer; persist between calls + interest::StepScratch scratch{}; + interest::DecisionTrace trace{}; // optional + interest::Reserve(scratch, 8); // setup may allocate; Step will not + interest::Reserve(trace, 8); + + interest::ObserverSnapshot observer{}; + observer.id = 1; + observer.referenceForward = {1.f, 0.f, 0.f}; // body/travel baseline + observer.context = 0; + + std::array candidates{}; + auto& candidate = candidates[0]; + candidate.id = 42; + candidate.position = {5.0, 0.0, 0.0}; + candidate.attraction = 0.8f; + candidate.salience = 0.7f; + candidate.tags = 1; + candidate.eventTimeMs = 1000; + candidate.eventStrength = 1.f; + + // Explicit monotonic host time; the event timestamp stays unchanged on replay. + for (const interest::TimeMs now : {1000, 1200}) { + interest::Decision decision{}; + const auto status = interest::Step(observer, candidates, profile, now, + memory, scratch, decision, &trace); + if (status != interest::StepStatus::Ok) { + std::fprintf(stderr, "Step failed: %s\n", interest::ToString(status)); + return 1; + } + switch (decision.kind) { + case interest::DecisionKind::Acquire: + case interest::DecisionKind::Keep: + case interest::DecisionKind::Switch: + // The host resolves the ID and drives gameplay or presentation. + std::printf("%s %llu (%s)\n", interest::ToString(decision.kind), + static_cast(decision.selected), + interest::ToString(decision.reason)); + break; + case interest::DecisionKind::Clear: + case interest::DecisionKind::None: + std::puts("No attention target"); + break; + } + if (decision.selected != 42 || + decision.kind != (now == 1000 ? interest::DecisionKind::Acquire + : interest::DecisionKind::Keep)) { + return 1; + } + } + return 0; +} +``` + +Expected output: + +```text +Acquire 42 (HighestScore) +Keep 42 (HighestScore) +``` + +Keep observer memory value-initialized or reset it with `ResetMemory()`; let `Step()` +maintain it thereafter. Resetting memory does not make an old event new. Reserve +for the largest submitted candidate count, including rejected candidates. Reserve +again after copying scratch/trace buffers because copying need not retain capacity. +Independent concurrent calls need distinct mutable memory, scratch, output and +trace, with snapshot/profile/context data held immutable during each call. Host +scheduling and synchronization are outside this library's validation scope. + +## Benchmarks + +CTest runs `interest_step_benchmark --smoke` to check the harness. For measurements, +run the optimized executable manually: + +```sh +./build/interest_step_benchmark --runs 5 --cycles 128 --raw build/samples.csv +``` + +With Visual Studio, use `./build/Release/interest_step_benchmark.exe` instead. +See [workloads, methodology and measured results](docs/benchmarks.md). These are +kernel-only synthetic measurements; perception, snapshot gathering and animation +must be budgeted separately. + +## Limits and stability + +- Spatial gates also attenuate novelty. An important event behind the observer or + beyond a Point cutoff may receive little or no score; the kernel has no urgency + bypass or semantic understanding of importance. +- Score saturation can hide a challenger's unsaturated advantage. Positive margins + can retain a target after novelty fades. Thresholds, weights, beta, half-life and + update cadence need host-specific tuning; defaults are not a gameplay policy. +- Work scales with submitted candidates and contexts. ID sorting and searches, + geometry, event math and optional trace writes have workload-dependent cost. + There is no real-time deadline or frame-budget guarantee. +- Memory holds only temporal selection state. There is no familiarity decay, + fatigue, candidate/event history, lost-target grace or multi-observer coordination. +- Validated scores stay bounded, but diagnostic fields may echo invalid inputs; + extreme finite coordinates may produce an infinite diagnostic distance and an + out-of-range rejection. Do not treat every trace field as a finite valid score. +- Regression scenarios and baseline comparisons are synthetic engineering tests. + They do not establish perceptual realism, biological validity or gameplay quality. + +See [contribution and validation guidance](CONTRIBUTING.md) for the test map and +change expectations. + +## License + +Copyright 2026 PullsarDev. Licensed under the [Apache License 2.0](LICENSE). +Test-support attribution is retained in [third-party notices](THIRD_PARTY_NOTICES.md). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..f9048f4 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,26 @@ +# Third-party notices + +The test-only `test::Random::Next()` in `tests/test_support.hpp` uses the SplitMix64 +sequence published by Sebastiano Vigna. It is also used by the benchmark harness; +it is not part of the production library. The wrapper uses explicit per-instance +state rather than the reference implementation's global state. + +The following notice is retained from the [primary reference implementation](https://prng.di.unimi.it/splitmix.c): + +```text +Written in 2015 by Sebastiano Vigna (vigna@acm.org) + +To the extent possible under law, the author has dedicated all copyright +and related and neighboring rights to this software to the public domain +worldwide. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +``` diff --git a/benchmarks/step_benchmark.cpp b/benchmarks/step_benchmark.cpp new file mode 100644 index 0000000..b0895ba --- /dev/null +++ b/benchmarks/step_benchmark.cpp @@ -0,0 +1,1473 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Kernel latency, throughput, allocation and memory measurements of the production +// Step(). Benchmark infrastructure only: it never reimplements scoring or policy. Every +// input frame is generated, and every scratch/trace/sample buffer reserved, before any +// timed region. A timed region contains one Step call (per-call mode) or one cycle of +// consecutive Step calls (batch mode). +// +// Compared variants are paired: event on/off variants consume the same RNG sequence and +// differ only in event fields; fixed and reshuffled orders submit the same snapshots per +// frame, permuted by a separate order RNG. CheckPairedConfigs/CheckReferencePairing +// assert this on the generated data in every mode, including --smoke. +// +// Usage: interest_step_benchmark [--smoke] [--runs R] [--cycles C] [--raw samples.csv] +// --smoke tiny counts for CTest; checks the harness invariants, makes no timing claim. +// --runs repetitions of the whole matrix and of the reference workload (default 5). +// --cycles replays of each 64-frame sequence per run and mode (default 128). +// --raw writes every per-call sample as CSV (section,config,run,index,ns). +// +// The method and recorded results are in docs/benchmarks.md. + +#include "test_support.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#if defined(_MSC_VER) +#pragma warning(push, 0) +#endif +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#if defined(_MSC_VER) +#pragma warning(pop) +#endif +#elif defined(__linux__) +#include +#endif + +// Counts every global allocation, as in interest_allocation_test. Measured regions must +// leave this unchanged. +namespace { +std::size_t allocationCount = 0; +} // namespace + +// Optimized GCC inlines these replacements into std::allocator code and then reports the +// free() as mismatched with operator new (-Wmismatched-new-delete), although the pair is +// malloc/free. Keeping them out of line avoids that false positive. +#if defined(__GNUC__) && !defined(__clang__) +#define INTEREST_BENCH_NOINLINE __attribute__((noinline)) +#else +#define INTEREST_BENCH_NOINLINE +#endif + +INTEREST_BENCH_NOINLINE void* operator new(std::size_t size) +{ + ++allocationCount; + if (void* memory = std::malloc(size == 0 ? 1 : size)) { + return memory; + } + std::abort(); // keeps the replacement valid when exceptions are disabled +} + +INTEREST_BENCH_NOINLINE void operator delete(void* memory) noexcept +{ + std::free(memory); +} + +INTEREST_BENCH_NOINLINE void operator delete(void* memory, std::size_t) noexcept +{ + std::free(memory); +} + +namespace { + +using interest::AttentionProfile; +using interest::CandidateId; +using interest::CandidateSnapshot; +using interest::ContextPreference; +using interest::Decision; +using interest::DecisionKind; +using interest::DecisionTrace; +using interest::Eligibility; +using interest::ObserverMemory; +using interest::ObserverSnapshot; +using interest::SpatialKind; +using interest::StepScratch; +using interest::StepStatus; +using interest::TagMask; +using interest::TimeMs; + +using Clock = std::chrono::steady_clock; + +constexpr TimeMs kFrameMs = 200; // 5 Hz observer update +constexpr TimeMs kEventPeriodMs = 2000; // an eventful source refreshes its event this often +constexpr TagMask kRequiredTag = 1; // context 0 requires bit 0 ... +constexpr TagMask kExcludedTag = 2; // ... and excludes bit 1 +constexpr TagMask kAffinityTags = 0x3FC; // bits 2..9 carry context affinities + +struct Options { + bool smoke = false; + int runs = 5; + int cycles = 128; + std::size_t frames = 64; + const char* rawPath = nullptr; +}; + +bool failed = false; + +void Require(bool ok, const char* what) +{ + if (!ok) { + std::fprintf(stderr, "HARNESS CHECK FAILED: %s\n", what); + failed = true; + } +} + +std::int64_t Nanoseconds(Clock::time_point begin, Clock::time_point end) +{ + return std::chrono::duration_cast(end - begin).count(); +} + +double Uniform(test::Random& rng, double lo, double hi) +{ + return lo + (hi - lo) * static_cast(rng.Below(1u << 20)) / double(1u << 20); +} + +// Decision fingerprint. It keeps every Step result observable (Step is an out-of-line +// call into the static library, so it cannot be elided anyway) and lets runs, modes and +// trace on/off variants be compared for identical decisions. +std::uint64_t Fold(std::uint64_t hash, const Decision& d) +{ + const std::uint64_t packed = std::uint64_t{std::bit_cast(d.effectiveScore)} | + (std::uint64_t{static_cast(d.kind)} << 32) | + (std::uint64_t{static_cast(d.reason)} << 40); + hash = (hash ^ d.selected) * 0x100000001B3ull; + return (hash ^ packed) * 0x100000001B3ull; +} + +constexpr std::uint64_t kHashSeed = 0xCBF29CE484222325ull; + +// Nearest-rank percentile of sorted values; q is in units of 1/100000. +std::int64_t Percentile(const std::vector& sorted, std::uint64_t q) +{ + if (sorted.empty()) { + return 0; + } + const std::uint64_t n = sorted.size(); + const std::uint64_t rank = (q * n + 99999) / 100000; + return sorted[static_cast(std::clamp(rank, 1, n) - 1)]; +} + +struct Distribution { + double mean = 0; + std::int64_t min = 0, p50 = 0, p95 = 0, p99 = 0, p999 = 0, max = 0; + std::size_t count = 0; +}; + +Distribution Summarize(std::vector values) +{ + Distribution d; + if (values.empty()) { + return d; + } + std::sort(values.begin(), values.end()); + double sum = 0; + for (const std::int64_t v : values) { + sum += static_cast(v); + } + d.count = values.size(); + d.mean = sum / static_cast(values.size()); + d.min = values.front(); + d.p50 = Percentile(values, 50000); + d.p95 = Percentile(values, 95000); + d.p99 = Percentile(values, 99000); + d.p999 = Percentile(values, 99900); + d.max = values.back(); + return d; +} + +// --- Profile and contexts ----------------------------------------------------------- + +std::vector MakeContexts() +{ + std::vector contexts(2); + contexts[0].requiredTags = kRequiredTag; + contexts[0].excludedTags = kExcludedTag; + contexts[1].affinityTags = kAffinityTags; + contexts[0].affinityTags = kAffinityTags; + for (std::size_t bit = 2; bit < 10; ++bit) { + contexts[0].tagAffinity[bit] = static_cast(bit - 1) / 8.f; + contexts[1].tagAffinity[bit] = static_cast(10 - bit) / 8.f; + } + return contexts; +} + +AttentionProfile MakeProfile(const std::vector& contexts, float beta) +{ + AttentionProfile profile{}; + profile.fullRange = 10.f; + profile.maxRange = 40.f; + profile.angleFloor = 0.2f; + profile.angleSharpness = 2.f; + profile.acquireThreshold = 0.5f; + profile.releaseThreshold = 0.4f; + profile.switchMargin = 0.08f; + profile.overrideMargin = 0.2f; + profile.minDwellMs = 0; // product default: dwell off + profile.contexts = contexts; + profile.noveltyBeta = beta; + profile.noveltyHalfLifeMs = 1000; + return profile; +} + +// --- Single-observer workloads -------------------------------------------------------- + +enum class Spatial : std::uint8_t { Mixed, Front, Surround, Far }; +enum class Reject : std::uint8_t { + None, Disabled, NotPerceived, MissingTag, ExcludedTag, OutOfRange, Duplicate }; + +struct Workload { + const char* name; + Spatial spatial; + int rejectPercent; // share of sources made ineligible, spread over six reasons + bool replace; // 25% of sources replaced by new IDs every frame + bool reshuffle; // submission order reshuffled every frame +}; + +struct Variant { + const char* name; + bool events; // 25% of sources carry a timestamped event + float beta; + bool trace; +}; + +// Generator state for one simulated stimulus; frames are materialized from it. +struct Source { + CandidateSnapshot base; + Reject reject = Reject::None; + bool eventful = false; + TimeMs eventPhase = 0; + float eventStrength = 0.f; +}; + +Source MakeSource(test::Random& rng, const Workload& w, bool events, std::size_t slot) +{ + Source s; + CandidateSnapshot& c = s.base; + c.id = rng.Next() | 1; // random nonzero IDs, so the duplicate-detection sort does real work + c.spatial = w.spatial == Spatial::Mixed ? static_cast(slot % 3) + : SpatialKind::Point; + const double pi = std::numbers::pi; + const double angle = w.spatial == Spatial::Front ? Uniform(rng, -pi / 3, pi / 3) + : Uniform(rng, -pi, pi); + double distance = Uniform(rng, 1.0, 38.0); + if (w.spatial == Spatial::Far && rng.Below(5) != 0) { + distance = Uniform(rng, 45.0, 100.0); // beyond the 40-unit cutoff + } + c.position = {distance * std::cos(angle), distance * std::sin(angle), Uniform(rng, -1, 1)}; + c.bearing = {static_cast(std::cos(angle)), static_cast(std::sin(angle)), 0.f}; + c.attraction = rng.Unit(); + c.salience = rng.Unit(); + c.perceptionStrength = 0.25f + 0.75f * rng.Unit(); + c.tags = kRequiredTag | (TagMask{1} << (2 + rng.Below(8))) | (TagMask{1} << (2 + rng.Below(8))); + if (static_cast(rng.Below(100)) < w.rejectPercent) { + s.reject = static_cast(1 + rng.Below(6)); + switch (s.reject) { + case Reject::Disabled: c.enabled = false; break; + case Reject::NotPerceived: c.perceptionStrength = 0.f; break; + case Reject::MissingTag: c.tags &= ~kRequiredTag; break; + case Reject::ExcludedTag: c.tags |= kExcludedTag; break; + case Reject::OutOfRange: + if (c.spatial == SpatialKind::Point) { + c.position = {60.0 * std::cos(angle), 60.0 * std::sin(angle), 0.0}; + } else { + c.enabled = false; // only Points have a range; recorded in the histogram + } + break; + default: break; // Duplicate is applied when a frame is materialized + } + } + // Event draws happen whether or not events are enabled, so event-on/off variants of a + // workload consume the same RNG sequence and differ only in the event fields. + const bool eventful = rng.Below(4) == 0; + s.eventful = events && eventful; + s.eventPhase = static_cast(rng.Below(static_cast(kEventPeriodMs))); + s.eventStrength = 0.5f + 0.5f * rng.Unit(); + return s; +} + +CandidateSnapshot Materialize(const Source& s, test::Random& rng, TimeMs now) +{ + CandidateSnapshot c = s.base; + if (c.spatial == SpatialKind::Point) { + c.position.x += Uniform(rng, -0.25, 0.25); + c.position.y += Uniform(rng, -0.25, 0.25); + } + c.salience = std::clamp(c.salience + 0.0625f * (rng.Unit() - 0.5f), 0.f, 1.f); + if (s.eventful && now >= s.eventPhase) { + c.eventTimeMs = now - (now - s.eventPhase) % kEventPeriodMs; + c.eventStrength = s.eventStrength; + } + return c; +} + +// Candidate data comes from `seed` alone and is materialized in canonical slot order; +// a reshuffling workload then permutes each finished frame with a separate order RNG. +// Fixed-order and reshuffled variants with the same seed therefore submit identical +// snapshots per frame, differing only in submission order. +std::vector> MakeFrames(const Workload& w, bool events, + std::size_t n, std::size_t frameCount, + std::uint64_t seed) +{ + test::Random rng(seed); + test::Random orderRng(seed ^ 0x0DE5C0DEull); + std::vector sources; + for (std::size_t i = 0; i < n; ++i) { + sources.push_back(MakeSource(rng, w, events, i)); + } + std::vector> frames(frameCount); + for (std::size_t f = 0; f < frameCount; ++f) { + if (w.replace && f > 0 && n > 0) { + for (std::size_t k = 0; k < n / 4; ++k) { + const auto slot = static_cast(rng.Below(n)); + sources[slot] = MakeSource(rng, w, events, slot); + } + } + const TimeMs now = static_cast(f) * kFrameMs; + for (std::size_t i = 0; i < n; ++i) { + frames[f].push_back(Materialize(sources[i], rng, now)); + if (sources[i].reject == Reject::Duplicate && i > 0) { + frames[f][i].id = frames[f][i - 1].id; // both occurrences are rejected + } + } + if (w.reshuffle && f > 0) { + orderRng.Shuffle(frames[f]); + } + } + return frames; +} + +struct Config { + std::string label; + const Workload* workload; + const Variant* variant; + std::size_t n; + std::vector> frames; + std::uint64_t checksum = 0; // one cycle's decision fingerprint + std::vector perCall; // pooled per-call samples, all runs + std::vector perCallRunP50; // per-run medians + std::vector batchMean; // per-run batch mean ns/step + // Untimed traced characterization of one cycle. + std::size_t eligible = 0, entries = 0, switches = 0, clears = 0, acquires = 0; + std::size_t rejected[16]{}; +}; + +// Workspace a host would keep: per-observer memory plus caller-prepared buffers. +struct Workspace { + ObserverMemory memory{}; + StepScratch scratch; + DecisionTrace trace; + Decision output{}; +}; + +// One untimed cycle; returns its fingerprint and, when traced, characterizes it. +std::uint64_t Cycle(Config& c, const ObserverSnapshot& observer, const AttentionProfile& profile, + Workspace& ws, bool characterize) +{ + interest::ResetMemory(ws.memory); + std::uint64_t hash = kHashSeed; + for (std::size_t f = 0; f < c.frames.size(); ++f) { + DecisionTrace* trace = c.variant->trace || characterize ? &ws.trace : nullptr; + const StepStatus status = + interest::Step(observer, c.frames[f], profile, static_cast(f) * kFrameMs, + ws.memory, ws.scratch, ws.output, trace); + Require(status == StepStatus::Ok, "warm-up Step returned Ok"); + hash = Fold(hash, ws.output); + if (characterize) { + for (const interest::CandidateTrace& e : ws.trace.candidates) { + ++c.entries; + if (e.eligibility == Eligibility::Eligible) { + ++c.eligible; + } else { + ++c.rejected[static_cast(e.eligibility)]; + } + } + c.switches += ws.output.kind == DecisionKind::Switch; + c.clears += ws.output.kind == DecisionKind::Clear; + c.acquires += ws.output.kind == DecisionKind::Acquire; + } + } + return hash; +} + +void MeasureRun(Config& c, const ObserverSnapshot& observer, const AttentionProfile& profile, + Workspace& ws, const Options& options, std::vector& runSamples) +{ + DecisionTrace* trace = c.variant->trace ? &ws.trace : nullptr; + const std::size_t frameCount = c.frames.size(); + + // Warm-up: one untimed cycle (caches, branch predictors, page faults). + Require(Cycle(c, observer, profile, ws, false) == c.checksum, "warm-up replay identical"); + + const std::size_t allocationsBefore = allocationCount; + bool allOk = true; + bool identical = true; + + // Per-call mode: one Step per timed region. + runSamples.clear(); // capacity reserved by the caller + for (int cycle = 0; cycle < options.cycles; ++cycle) { + interest::ResetMemory(ws.memory); + std::uint64_t hash = kHashSeed; + for (std::size_t f = 0; f < frameCount; ++f) { + const TimeMs now = static_cast(f) * kFrameMs; + const Clock::time_point begin = Clock::now(); + const StepStatus status = interest::Step(observer, c.frames[f], profile, now, + ws.memory, ws.scratch, ws.output, trace); + const Clock::time_point end = Clock::now(); + runSamples.push_back(Nanoseconds(begin, end)); + allOk = allOk && status == StepStatus::Ok; + hash = Fold(hash, ws.output); + } + identical = identical && hash == c.checksum; + } + + // Batch mode: one timed region per cycle, without per-call timer overhead. The + // fingerprint fold (two multiply-xors per step) stays inside the timed region. + std::int64_t batchNs = 0; + for (int cycle = 0; cycle < options.cycles; ++cycle) { + interest::ResetMemory(ws.memory); + std::uint64_t hash = kHashSeed; + bool ok = true; + const Clock::time_point begin = Clock::now(); + for (std::size_t f = 0; f < frameCount; ++f) { + ok = interest::Step(observer, c.frames[f], profile, static_cast(f) * kFrameMs, + ws.memory, ws.scratch, ws.output, trace) == StepStatus::Ok && ok; + hash = Fold(hash, ws.output); + } + const Clock::time_point end = Clock::now(); + batchNs += Nanoseconds(begin, end); + allOk = allOk && ok; + identical = identical && hash == c.checksum; + } + + Require(allocationCount == allocationsBefore, "no allocations in measured Step regions"); + Require(allOk, "every measured Step returned Ok"); + Require(identical, "measured cycles replay the warm-up decisions exactly"); + + c.perCall.insert(c.perCall.end(), runSamples.begin(), runSamples.end()); + std::vector sorted = runSamples; + std::sort(sorted.begin(), sorted.end()); + c.perCallRunP50.push_back(Percentile(sorted, 50000)); + c.batchMean.push_back(static_cast(batchNs) / + static_cast(frameCount * static_cast(options.cycles))); +} + +// --- Reference workload: 100 observers x 500 candidates x 5 Hz -------------------------- + +constexpr std::size_t kRefObservers = 100; +constexpr std::size_t kRefCandidates = 500; +constexpr std::size_t kRefTicks = 50; // 10 simulated seconds at 5 Hz +constexpr auto kTicksPerSecond = static_cast(1000 / kFrameMs); + +struct WorldSource { + Source source; + double vx = 0, vy = 0; // units per second, Points only +}; + +WorldSource MakeWorldSource(test::Random& rng, bool events) +{ + WorldSource w; + CandidateSnapshot& c = w.source.base; + c.id = rng.Next() | 1; + const std::uint64_t kind = rng.Below(20); // 70% Point, 15% Bearing, 15% NonSpatial + c.spatial = kind < 14 ? SpatialKind::Point + : kind < 17 ? SpatialKind::Bearing : SpatialKind::NonSpatial; + const double pi = std::numbers::pi; + const double angle = Uniform(rng, -pi, pi); + const double radius = 60.0 * std::sqrt(Uniform(rng, 0, 1)); // uniform over a disc + c.position = {radius * std::cos(angle), radius * std::sin(angle), Uniform(rng, 0, 2)}; + c.bearing = {static_cast(std::cos(angle)), static_cast(std::sin(angle)), 0.f}; + const double heading = Uniform(rng, -pi, pi); + const double speed = Uniform(rng, 0, 1.5); + w.vx = speed * std::cos(heading); + w.vy = speed * std::sin(heading); + c.attraction = rng.Unit(); + c.salience = rng.Unit(); + c.perceptionStrength = 0.25f + 0.75f * rng.Unit(); + c.tags = (TagMask{1} << (2 + rng.Below(8))) | (TagMask{1} << (2 + rng.Below(8))); + if (rng.Below(10) != 0) { + c.tags |= kRequiredTag; // 90% satisfy context 0 + } + if (rng.Below(33) == 0) { + c.tags |= kExcludedTag; + } + c.enabled = rng.Below(20) != 0; // 5% disabled + const bool eventful = rng.Below(4) == 0; // drawn either way: see MakeSource + w.source.eventful = events && eventful; + w.source.eventPhase = static_cast(rng.Below(static_cast(kEventPeriodMs))); + w.source.eventStrength = 0.5f + 0.5f * rng.Unit(); + return w; +} + +std::vector> MakeWorldFrames(bool events, std::size_t ticks) +{ + test::Random rng(0xB3E7C0DEull); + std::vector world; + for (std::size_t i = 0; i < kRefCandidates; ++i) { + world.push_back(MakeWorldSource(rng, events)); + } + std::vector> frames(ticks); + for (std::size_t t = 0; t < ticks; ++t) { + if (t > 0) { + for (int k = 0; k < 10; ++k) { // 2% of sources replaced per tick + world[static_cast(rng.Below(kRefCandidates))] = MakeWorldSource(rng, events); + } + } + const TimeMs now = static_cast(t) * kFrameMs; + const double seconds = static_cast(kFrameMs) / 1000.0; + for (WorldSource& w : world) { + if (w.source.base.spatial == SpatialKind::Point) { + w.source.base.position.x += w.vx * seconds; + w.source.base.position.y += w.vy * seconds; + } + frames[t].push_back(Materialize(w.source, rng, now)); + } + } + return frames; +} + +std::vector MakeObservers() +{ + test::Random rng(0x0B5E7E25ull); + std::vector observers(kRefObservers); + const double pi = std::numbers::pi; + for (std::size_t i = 0; i < kRefObservers; ++i) { + const double angle = Uniform(rng, -pi, pi); + const double radius = 30.0 * std::sqrt(Uniform(rng, 0, 1)); + const double facing = Uniform(rng, -pi, pi); + observers[i].id = i + 1; + observers[i].position = {radius * std::cos(angle), radius * std::sin(angle), 0.0}; + observers[i].referenceForward = {static_cast(std::cos(facing)), + static_cast(std::sin(facing)), 0.f}; + observers[i].context = static_cast(i % 2); + } + return observers; +} + +// Each observer submits the same world in its own gather order (a fixed per-observer +// permutation, as an observer-specific spatial query would produce), copied into its own +// host buffer before the tick, outside every timed region. With sharedOrder all +// observers use the world order instead; only the order differs. +void Gather(const std::vector& world, + const std::vector>& orders, + std::vector>& gathered) +{ + for (std::size_t o = 0; o < gathered.size(); ++o) { + for (std::size_t i = 0; i < world.size(); ++i) { + gathered[o][i] = world[orders[o][i]]; + } + } +} + +// One reference-workload variant: its world, observers and host-owned state. Variants +// are measured interleaved (every run measures every variant once), so machine drift +// over the session lands on all variants alike instead of separating them. +class ReferenceVariant { +public: + ReferenceVariant(const char* name, bool events, float beta, bool traced, bool sharedOrder, + const std::vector& contexts, const Options& options) + : name_(name), events_(events), beta_(beta), traced_(traced), sharedOrder_(sharedOrder), + ticks_(options.smoke ? 3 : kRefTicks), world_(MakeWorldFrames(events, ticks_)), + observers_(MakeObservers()), profile_(MakeProfile(contexts, beta)), + orders_(kRefObservers, std::vector(kRefCandidates)), + frames_(kRefObservers, std::vector(kRefCandidates)), + memories_(kRefObservers) + { + test::Random orderRng(0x0D0E5ull); + for (std::vector& order : orders_) { + for (std::size_t i = 0; i < kRefCandidates; ++i) { + order[i] = i; + } + if (!sharedOrder_) { + orderRng.Shuffle(order); + } + } + // Host-owned state: one memory per observer; one scratch/trace per (single) + // worker, since scratch contents are unspecified between calls and the trace is + // read at once. + interest::Reserve(scratch_, kRefCandidates); + interest::Reserve(trace_, kRefCandidates); + observerSum_.assign(kRefObservers, 0.0); + observerEligible_.assign(kRefObservers, 0.0); + const std::size_t stepsPerPass = ticks_ * kRefObservers; + steps_.reserve(stepsPerPass * static_cast(options.runs)); + ticksNs_.reserve(ticks_ * static_cast(options.runs)); + runTickMean_.reserve(static_cast(options.runs)); + passSamples_.reserve(stepsPerPass); + + // Untimed traced pass: warm-up, reference fingerprint and workload characterization. + ResetMemories(); + std::uint64_t hash = kHashSeed; + for (std::size_t t = 0; t < ticks_; ++t) { + Gather(world_[t], orders_, frames_); // host gathering, untimed + for (std::size_t o = 0; o < kRefObservers; ++o) { + Require(interest::Step(observers_[o], frames_[o], profile_, + static_cast(t) * kFrameMs, memories_[o], scratch_, + output_, &trace_) == StepStatus::Ok, + "reference warm-up Step returned Ok"); + hash = Fold(hash, output_); + std::size_t eligible = 0; + for (const interest::CandidateTrace& e : trace_.candidates) { + eligible += e.eligibility == Eligibility::Eligible; + } + observerEligible_[o] += static_cast(eligible) / static_cast(ticks_); + switches_ += output_.kind == DecisionKind::Switch; + clears_ += output_.kind == DecisionKind::Clear; + acquires_ += output_.kind == DecisionKind::Acquire; + } + } + checksum_ = hash; + } + + // One run: pass 1 times every Step individually; pass 2 times each tick's 100 Steps + // back to back, as one worker would run them. + void MeasureRun(int run, std::FILE* raw) + { + DecisionTrace* trace = traced_ ? &trace_ : nullptr; + const std::size_t allocationsBefore = allocationCount; + ResetMemories(); + std::uint64_t hash = kHashSeed; + passSamples_.clear(); + for (std::size_t t = 0; t < ticks_; ++t) { + Gather(world_[t], orders_, frames_); // host gathering, untimed + const TimeMs now = static_cast(t) * kFrameMs; + for (std::size_t o = 0; o < kRefObservers; ++o) { + const Clock::time_point begin = Clock::now(); + const StepStatus status = interest::Step(observers_[o], frames_[o], profile_, now, + memories_[o], scratch_, output_, trace); + const Clock::time_point end = Clock::now(); + const std::int64_t ns = Nanoseconds(begin, end); + passSamples_.push_back(ns); + observerSum_[o] += static_cast(ns); + allOk_ = allOk_ && status == StepStatus::Ok; + hash = Fold(hash, output_); + } + } + identical_ = identical_ && hash == checksum_; + steps_.insert(steps_.end(), passSamples_.begin(), passSamples_.end()); + + ResetMemories(); + hash = kHashSeed; + std::int64_t runTotal = 0; + for (std::size_t t = 0; t < ticks_; ++t) { + Gather(world_[t], orders_, frames_); // host gathering, untimed + const TimeMs now = static_cast(t) * kFrameMs; + bool ok = true; + const Clock::time_point begin = Clock::now(); + for (std::size_t o = 0; o < kRefObservers; ++o) { + ok = interest::Step(observers_[o], frames_[o], profile_, now, memories_[o], + scratch_, output_, trace) == StepStatus::Ok && ok; + hash = Fold(hash, output_); + } + const Clock::time_point end = Clock::now(); + ticksNs_.push_back(Nanoseconds(begin, end)); + runTotal += ticksNs_.back(); + allOk_ = allOk_ && ok; + } + identical_ = identical_ && hash == checksum_; + runTickMean_.push_back(static_cast(runTotal) / static_cast(ticks_)); + allocationFree_ = allocationFree_ && allocationCount == allocationsBefore; + + if (raw != nullptr) { // outside both passes + for (std::size_t i = 0; i < passSamples_.size(); ++i) { + std::fprintf(raw, "reference,%s,%d,%zu,%lld\n", name_, run, i, + static_cast(passSamples_[i])); + } + } + } + + // Checks the run invariants and, outside smoke mode, prints the distributions. + std::uint64_t Report(const Options& options) const + { + Require(allocationFree_, "no allocations in reference Step regions"); + Require(allOk_, "every reference Step returned Ok"); + Require(identical_, "reference passes replay the warm-up decisions exactly"); + if (options.smoke) { + return checksum_; + } + const Distribution step = Summarize(steps_); + const Distribution tick = Summarize(ticksNs_); + std::printf("\n[%s] events %s, beta %.2f, trace %s, %s; %zu observers x %zu candidates," + " %zu ticks (%zu s simulated) x %d runs\n", name_, events_ ? "on" : "off", + static_cast(beta_), traced_ ? "on" : "off", + sharedOrder_ ? "one shared submission order" : "per-observer gather order", + kRefObservers, kRefCandidates, ticks_, ticks_ / kTicksPerSecond, options.runs); + std::printf(" per-Step ns (n=%zu): mean %.0f p50 %lld p95 %lld p99 %lld p99.9 %lld max %lld\n", + step.count, step.mean, static_cast(step.p50), + static_cast(step.p95), static_cast(step.p99), + static_cast(step.p999), static_cast(step.max)); + std::printf(" per-tick us (n=%zu, 100 Steps back to back): mean %.1f p50 %.1f p99 %.1f max %.1f\n", + tick.count, tick.mean / 1000.0, static_cast(tick.p50) / 1000.0, + static_cast(tick.p99) / 1000.0, static_cast(tick.max) / 1000.0); + const double kernelNsPerSimSecond = tick.mean * static_cast(kTicksPerSecond); + const double stepsPerSecond = static_cast(kRefObservers) * 1e9 / tick.mean; + std::printf(" aggregate: %.3f ms kernel time per simulated second on one worker (%.3f%% of one core);" + " achieved %.0f Steps/s = %.3g candidate evaluations/s\n", + kernelNsPerSimSecond / 1e6, kernelNsPerSimSecond / 1e7, stepsPerSecond, + stepsPerSecond * static_cast(kRefCandidates)); + + // Per-observer cost: mean per-call ns of each observer, with its eligible count. + std::vector order(kRefObservers); + for (std::size_t i = 0; i < kRefObservers; ++i) { + order[i] = i; + } + const double perObserverSteps = static_cast(ticks_) * options.runs; + std::sort(order.begin(), order.end(), [this](std::size_t a, std::size_t b) { + return observerSum_[a] < observerSum_[b]; + }); + const auto line = [&](const char* label, std::size_t index) { + const std::size_t o = order[index]; + std::printf(" %-6s observer %3zu: %7.0f ns/Step, %5.1f eligible of %zu\n", label, + o + 1, observerSum_[o] / perObserverSteps, observerEligible_[o], + kRefCandidates); + }; + std::printf(" per-observer mean cost (sorted over %zu observers):\n", kRefObservers); + line("min", 0); + line("p10", kRefObservers / 10); + line("median", kRefObservers / 2); + line("p90", kRefObservers * 9 / 10); + line("max", kRefObservers - 1); + double eligibleTotal = 0; + for (const double e : observerEligible_) { + eligibleTotal += e; + } + std::printf(" workload: %.1f%% of submitted candidates eligible; per pass %zu acquires," + " %zu switches, %zu clears; fingerprint %016llx\n", + 100.0 * eligibleTotal / static_cast(kRefObservers * kRefCandidates), + acquires_, switches_, clears_, static_cast(checksum_)); + return checksum_; + } + + const char* Name() const { return name_; } + const std::vector& RunTickMeans() const { return runTickMean_; } + +private: + void ResetMemories() + { + for (ObserverMemory& m : memories_) { + interest::ResetMemory(m); + } + } + + const char* name_; + bool events_; + float beta_; + bool traced_; + bool sharedOrder_; + std::size_t ticks_; + std::vector> world_; + std::vector observers_; + AttentionProfile profile_; + std::vector> orders_; + std::vector> frames_; // per-observer gathered buffers + std::vector memories_; + StepScratch scratch_; + DecisionTrace trace_; + Decision output_{}; + std::vector steps_; // pooled per-Step samples + std::vector ticksNs_; // pooled per-tick totals (100 back-to-back Steps) + std::vector runTickMean_; // per-run mean tick time, for paired ratios + std::vector observerSum_; // per-observer summed per-call ns + std::vector observerEligible_; // per-observer mean eligible candidates per step + std::vector passSamples_; + std::size_t switches_ = 0, clears_ = 0, acquires_ = 0; + std::uint64_t checksum_ = 0; + bool allOk_ = true; + bool identical_ = true; + bool allocationFree_ = true; +}; + +// --- Environment, timer and memory reports --------------------------------------------- + +void PrintEnvironment() +{ +#if defined(_MSC_FULL_VER) + std::printf("compiler: MSVC %d\n", _MSC_FULL_VER); +#elif defined(__clang__) + std::printf("compiler: Clang %s\n", __clang_version__); +#elif defined(__GNUC__) + std::printf("compiler: GCC %d.%d.%d\n", __GNUC__, __GNUC_MINOR__, __GNUC_PATCHLEVEL__); +#endif +#if defined(NDEBUG) + std::printf("build: NDEBUG (optimized configuration)\n"); +#else + std::printf("build: assertions enabled (Debug configuration)\n"); +#endif + std::printf("hardware threads: %u; pointer size %zu\n", std::thread::hardware_concurrency(), + sizeof(void*)); + std::printf("clock: steady_clock, period %lld/%lld s\n", + static_cast(Clock::period::num), static_cast(Clock::period::den)); +} + +void MeasureTimer(bool smoke) +{ + const std::size_t pairs = smoke ? 1000 : 1000000; + std::vector samples; + samples.reserve(pairs); + for (std::size_t i = 0; i < pairs; ++i) { + const Clock::time_point begin = Clock::now(); + const Clock::time_point end = Clock::now(); + samples.push_back(Nanoseconds(begin, end)); + } + // Granularity: smallest positive step between successive distinct readings. + std::int64_t granularity = INT64_MAX; + for (int i = 0; i < 1000; ++i) { + const Clock::time_point start = Clock::now(); + Clock::time_point next = Clock::now(); + while (next == start) { + next = Clock::now(); + } + granularity = std::min(granularity, Nanoseconds(start, next)); + } + const Distribution d = Summarize(samples); + std::printf("timer: granularity %lld ns; empty timed region (n=%zu): mean %.1f p50 %lld p99 %lld" + " max %lld ns\n", static_cast(granularity), d.count, d.mean, + static_cast(d.p50), static_cast(d.p99), + static_cast(d.max)); +} + +void PrintMemory() +{ + std::printf("\nObject sizes (bytes, sizeof/alignof):\n"); + std::printf(" ObserverMemory %zu/%zu ObserverSnapshot %zu/%zu CandidateSnapshot %zu/%zu\n", + sizeof(ObserverMemory), alignof(ObserverMemory), sizeof(ObserverSnapshot), + alignof(ObserverSnapshot), sizeof(CandidateSnapshot), alignof(CandidateSnapshot)); + std::printf(" CandidateTrace %zu/%zu Decision %zu/%zu StepScratch %zu DecisionTrace %zu" + " AttentionProfile %zu ContextPreference %zu\n", + sizeof(interest::CandidateTrace), alignof(interest::CandidateTrace), + sizeof(Decision), alignof(Decision), sizeof(StepScratch), sizeof(DecisionTrace), + sizeof(AttentionProfile), sizeof(ContextPreference)); + std::printf("Reserved heap storage after Reserve(n) (capacity x element size, excluding" + " allocator overhead):\n"); + for (const std::size_t n : {std::size_t{0}, std::size_t{16}, std::size_t{100}, std::size_t{500}}) { + StepScratch scratch; + DecisionTrace trace; + const std::size_t before = allocationCount; + interest::Reserve(scratch, n); + interest::Reserve(trace, n); + const std::size_t allocations = allocationCount - before; + std::printf(" n=%3zu: scratch capacity %3zu = %5zu B; trace capacity %3zu = %6zu B;" + " %zu allocation(s) by Reserve\n", n, scratch.sortedIds.capacity(), + scratch.sortedIds.capacity() * sizeof(CandidateId), trace.candidates.capacity(), + trace.candidates.capacity() * sizeof(interest::CandidateTrace), allocations); + } + const std::size_t scratchBytes = kRefCandidates * sizeof(CandidateId); + const std::size_t traceBytes = kRefCandidates * sizeof(interest::CandidateTrace); + std::printf("Reference workload (%zu observers, %zu candidates, one worker):\n", kRefObservers, + kRefCandidates); + std::printf(" retained per observer: ObserverMemory %zu B -> %zu B for all observers\n", + sizeof(ObserverMemory), kRefObservers * sizeof(ObserverMemory)); + std::printf(" per worker: scratch %zu B + optional trace %zu B (contents need not persist)\n", + scratchBytes, traceBytes); + std::printf(" if every observer owned buffers instead: scratch %zu B, trace %zu B\n", + kRefObservers * scratchBytes, kRefObservers * traceBytes); + std::printf(" host-owned input: one %zu-candidate snapshot array = %zu B\n", kRefCandidates, + kRefCandidates * sizeof(CandidateSnapshot)); +} + +void PrintPeakProcessMemory() +{ +#if defined(_WIN32) + PROCESS_MEMORY_COUNTERS counters{}; + if (GetProcessMemoryInfo(GetCurrentProcess(), &counters, sizeof(counters))) { + std::printf("peak process working set: %.1f MiB (benchmark process, dominated by" + " pre-generated input frames; not kernel memory)\n", + static_cast(counters.PeakWorkingSetSize) / (1024.0 * 1024.0)); + } +#elif defined(__linux__) + rusage usage{}; + if (getrusage(RUSAGE_SELF, &usage) == 0) { + std::printf("peak process RSS: %.1f MiB (benchmark process, dominated by pre-generated" + " input frames; not kernel memory)\n", + static_cast(usage.ru_maxrss) / 1024.0); + } +#endif +} + +// A deliberately under-reserved Step is rejected without mutation or allocation. +void CheckUnderReserved(const std::vector& contexts) +{ + const Workload w{"mixed", Spatial::Mixed, 0, false, false}; + const auto frames = MakeFrames(w, true, 100, 1, 0x5EED0B01ull); + const AttentionProfile profile = MakeProfile(contexts, 0.75f); + StepScratch scratch; + DecisionTrace trace; + interest::Reserve(scratch, 99); + interest::Reserve(trace, 100); + ObserverMemory memory{}; + memory.current = 7; + memory.hasStepped = true; + Decision output{}; + output.selected = 3; + const ObserverMemory memoryBefore = memory; + const std::size_t before = allocationCount; + const StepStatus status = interest::Step(test::Observer(), frames[0], profile, 0, memory, + scratch, output, &trace); + Require(status == StepStatus::InsufficientCapacity && allocationCount == before && + test::SameMemory(memory, memoryBefore) && output.selected == 3 && + trace.candidates.empty() && scratch.sortedIds.capacity() == 99, + "under-reserved scratch is rejected without mutation or allocation"); + interest::Reserve(scratch, 100); + const std::size_t reserved = allocationCount; + const StepStatus retry = interest::Step(test::Observer(), frames[0], profile, 0, memory, + scratch, output, &trace); + Require(retry == StepStatus::Ok && allocationCount == reserved, + "the same Step succeeds without allocation once capacity is reserved"); + std::printf("under-reserved check: capacity 99 for 100 candidates -> %s, memory/output/trace" + " unchanged, 0 allocations; after Reserve(100) -> %s\n", + interest::ToString(status), interest::ToString(retry)); +} + +double MedianBatch(const Config& c) +{ + std::vector batch = c.batchMean; + std::sort(batch.begin(), batch.end()); + return batch[batch.size() / 2]; +} + +// Attribution estimates measured outside Step. ValidateProfile is the public function +// that Step calls first on every call. The duplicate-detection primitive repeats Step's +// own library calls (copy the IDs, std::sort, one std::equal_range per candidate) on the +// same frames. Neither replaces the Step measurements; they only apportion them. +void MeasureIsolatedCosts(const AttentionProfile& profile, const std::vector& configs, + const Workload& mixed, const Workload& shuffle, const Variant& core, + const Options& options) +{ + std::size_t allocations = allocationCount; + const std::size_t calls = options.smoke ? 1000 : 2000000; + std::size_t valid = 0; + const Clock::time_point begin = Clock::now(); + for (std::size_t i = 0; i < calls; ++i) { + valid += interest::ValidateProfile(profile) == StepStatus::Ok; // out-of-line, not hoisted + } + const Clock::time_point end = Clock::now(); + allocations = allocationCount - allocations; + Require(valid == calls, "ValidateProfile accepts the benchmark profile"); + const double validateNs = static_cast(Nanoseconds(begin, end)) / static_cast(calls); + + std::vector ids; + ids.reserve(500); + if (!options.smoke) { + std::printf("\nIsolated attribution estimates (measured outside Step):\n"); + } + for (const Config& c : configs) { + // kDistributions holds a copy of the shuffle workload, so compare by name. + if (c.variant != &core || (std::strcmp(c.workload->name, mixed.name) != 0 && + std::strcmp(c.workload->name, shuffle.name) != 0)) { + continue; + } + if (c.n == 0) { + if (!options.smoke) { + std::printf(" ValidateProfile (2 contexts): %.1f ns/call = %.0f%% of %s n=0 batch" + " (%.1f ns)\n", validateNs, 100.0 * validateNs / MedianBatch(c), + c.label.c_str(), MedianBatch(c)); + } + continue; + } + std::size_t duplicates = 0; + const std::size_t before = allocationCount; + const Clock::time_point start = Clock::now(); + for (int cycle = 0; cycle < options.cycles; ++cycle) { + for (const std::vector& frame : c.frames) { + ids.clear(); + for (const CandidateSnapshot& candidate : frame) { + ids.push_back(candidate.id); + } + std::sort(ids.begin(), ids.end()); + for (const CandidateSnapshot& candidate : frame) { + const auto range = std::equal_range(ids.begin(), ids.end(), candidate.id); + duplicates += range.second - range.first > 1; + } + } + } + const Clock::time_point stop = Clock::now(); + allocations += allocationCount - before; + Require(duplicates == 0, "benchmark mixed/shuffle frames have unique IDs"); + const double perStep = static_cast(Nanoseconds(start, stop)) / + static_cast(c.frames.size() * static_cast(options.cycles)); + if (!options.smoke) { + std::printf(" duplicate detection (copy IDs + sort + equal_range), %-8s n=%3zu: %8.1f" + " ns/Step = %4.1f%% of its Step batch (%.1f ns)\n", c.workload->name, c.n, + perStep, 100.0 * perStep / MedianBatch(c), MedianBatch(c)); + } + } + Require(allocations == 0, "no allocations in isolated measurements"); +} + +// --- Cross-configuration pairing checks ------------------------------------------------ +// A comparison between two configurations is attributable to the factor they are meant to +// differ in only if their inputs are otherwise identical. These checks assert that on the +// generated snapshots directly, independently of each configuration's own replay. + +std::uint32_t Bits(float v) { return std::bit_cast(v); } +std::uint64_t Bits(double v) { return std::bit_cast(v); } + +// Every snapshot field except the event pair, bitwise. +auto NonEventKey(const CandidateSnapshot& c) +{ + return std::make_tuple(c.id, static_cast(c.spatial), Bits(c.position.x), + Bits(c.position.y), Bits(c.position.z), Bits(c.bearing.x), + Bits(c.bearing.y), Bits(c.bearing.z), Bits(c.attraction), + Bits(c.salience), Bits(c.perceptionStrength), Bits(c.maxDistance), + c.tags, c.enabled); +} + +auto FullKey(const CandidateSnapshot& c) +{ + return std::tuple_cat(NonEventKey(c), std::make_tuple(c.eventTimeMs, Bits(c.eventStrength))); +} + +bool HasEvent(const CandidateSnapshot& c) +{ + return c.eventTimeMs != 0 || Bits(c.eventStrength) != 0; +} + +using Frames = std::vector>; + +// Same snapshots at the same positions. With `eventsOnly`, `withoutEvents` must carry no +// event data and the two may differ only in the event pair. +bool SamePerIndex(const Frames& withoutEvents, const Frames& other, bool eventsOnly, + std::size_t& eventEntries) +{ + if (withoutEvents.size() != other.size()) { + return false; + } + for (std::size_t f = 0; f < other.size(); ++f) { + if (withoutEvents[f].size() != other[f].size()) { + return false; + } + for (std::size_t i = 0; i < other[f].size(); ++i) { + const CandidateSnapshot& a = withoutEvents[f][i]; + const CandidateSnapshot& b = other[f][i]; + eventEntries += HasEvent(b); + const bool same = eventsOnly ? !HasEvent(a) && NonEventKey(a) == NonEventKey(b) + : FullKey(a) == FullKey(b); + if (!same) { + return false; + } + } + } + return true; +} + +// Same multiset of snapshots in every frame; counts frames whose order differs. +bool SameMultisetPerFrame(const Frames& a, const Frames& b, std::size_t& reordered) +{ + if (a.size() != b.size()) { + return false; + } + using Key = decltype(FullKey(CandidateSnapshot{})); + std::vector ka; + std::vector kb; + for (std::size_t f = 0; f < a.size(); ++f) { + if (a[f].size() != b[f].size()) { + return false; + } + ka.clear(); + kb.clear(); + bool sameOrder = true; + for (std::size_t i = 0; i < a[f].size(); ++i) { + ka.push_back(FullKey(a[f][i])); + kb.push_back(FullKey(b[f][i])); + sameOrder = sameOrder && ka.back() == kb.back(); + } + reordered += !sameOrder; + std::sort(ka.begin(), ka.end()); + std::sort(kb.begin(), kb.end()); + if (ka != kb) { + return false; + } + } + return true; +} + +bool SameCharacterization(const Config& a, const Config& b) +{ + return a.entries == b.entries && a.eligible == b.eligible && + std::equal(std::begin(a.rejected), std::end(a.rejected), std::begin(b.rejected)); +} + +// Pairs every configuration with every other generated from the same workload data (same +// spatial kind, rejection share, replacement and n): +// - same order and event setting: identical snapshots (the trace and beta variants); +// - same order, events on vs off: identical except the event pair, absent when off; +// - fixed vs reshuffled order, same event setting: identical snapshot multisets per frame. +// Paired variants must also have identical eligibility, and every pair whose decisions +// must agree (same beta, and same events unless beta is 0, where an event cannot change +// an effective score) must have an equal decision fingerprint. +void CheckPairedConfigs(const std::vector& configs) +{ + std::size_t identical = 0, eventOnly = 0, orderOnly = 0, decisions = 0; + std::size_t eventEntries = 0, reorderedFrames = 0; + const bool failedBefore = failed; + for (std::size_t i = 0; i < configs.size(); ++i) { + for (std::size_t j = i + 1; j < configs.size(); ++j) { + const Config& a = configs[i]; + const Config& b = configs[j]; + const Workload& wa = *a.workload; + const Workload& wb = *b.workload; + if (a.n != b.n || wa.spatial != wb.spatial || wa.rejectPercent != wb.rejectPercent || + wa.replace != wb.replace) { + continue; + } + const bool sameEvents = a.variant->events == b.variant->events; + if (wa.reshuffle == wb.reshuffle) { + const Config& off = a.variant->events ? b : a; + const Config& on = a.variant->events ? a : b; + Require(SamePerIndex(off.frames, on.frames, !sameEvents, eventEntries), + sameEvents ? "same-order variants submit identical snapshots" + : "event on/off variants differ only in event fields"); + ++(sameEvents ? identical : eventOnly); + } else if (sameEvents) { + Require(SameMultisetPerFrame(a.frames, b.frames, reorderedFrames), + "fixed and reshuffled variants submit the same snapshots per frame"); + ++orderOnly; + } else { + continue; // order and events both differ: not a designed comparison + } + Require(SameCharacterization(a, b), "paired variants have identical eligibility"); + if (a.variant->beta == b.variant->beta && (sameEvents || a.variant->beta == 0.f)) { + Require(a.checksum == b.checksum, "paired variants make identical decisions"); + ++decisions; + } + } + } + Require(identical > 0 && eventOnly > 0 && orderOnly > 0 && decisions > 0, + "every pairing category was checked"); + Require(eventEntries > 0, "event variants actually carry events"); + Require(reorderedFrames > 0, "reshuffled variants actually change submission order"); + std::printf("paired-input checks %s: %zu identical-snapshot pairs, %zu event-only pairs," + " %zu order-only pairs (%zu reordered frames), %zu equal-decision pairs\n", + failed && !failedBefore ? "FAILED" : "passed", identical, eventOnly, orderOnly, + reorderedFrames, decisions); +} + +// The reference scene's core and novelty worlds differ only in the event pair. +void CheckReferencePairing(const Options& options) +{ + const std::size_t ticks = options.smoke ? 3 : kRefTicks; + std::size_t eventEntries = 0; + const bool same = SamePerIndex(MakeWorldFrames(false, ticks), MakeWorldFrames(true, ticks), + true, eventEntries); + Require(same && eventEntries > 0, "reference core/novelty worlds differ only in event fields"); + std::printf("reference pairing %s: core and novelty worlds identical except %zu event" + " entries over %zu ticks\n", same && eventEntries > 0 ? "passed" : "FAILED", + eventEntries, ticks); +} + +const char* ShortReason(std::size_t e) +{ + switch (static_cast(e)) { + case Eligibility::InvalidId: return "id"; + case Eligibility::DuplicateId: return "dup"; + case Eligibility::InvalidValue: return "val"; + case Eligibility::InvalidGeometry: return "geo"; + case Eligibility::Disabled: return "dis"; + case Eligibility::NotPerceived: return "np"; + case Eligibility::MissingRequiredTag: return "req"; + case Eligibility::ExcludedTag: return "exc"; + case Eligibility::OutOfRange: return "oor"; + case Eligibility::InvalidEvent: return "evt"; + default: return "?"; + } +} + +} // namespace + +int main(int argc, char** argv) +{ + Options options; + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--smoke") == 0) { + options.smoke = true; + } else if (std::strcmp(argv[i], "--runs") == 0 && i + 1 < argc) { + options.runs = std::max(1, std::atoi(argv[++i])); + } else if (std::strcmp(argv[i], "--cycles") == 0 && i + 1 < argc) { + options.cycles = std::max(1, std::atoi(argv[++i])); + } else if (std::strcmp(argv[i], "--raw") == 0 && i + 1 < argc) { + options.rawPath = argv[++i]; + } else { + std::fprintf(stderr, "usage: %s [--smoke] [--runs R] [--cycles C] [--raw file.csv]\n", + argv[0]); + return 2; + } + } + if (options.smoke) { + options.runs = 1; + options.cycles = 2; + options.frames = 4; + } + std::FILE* raw = nullptr; + if (options.rawPath != nullptr) { +#if defined(_MSC_VER) + if (fopen_s(&raw, options.rawPath, "w") != 0) { raw = nullptr; } +#else + raw = std::fopen(options.rawPath, "w"); +#endif + if (raw == nullptr) { + std::fprintf(stderr, "cannot open %s\n", options.rawPath); + return 2; + } + std::fprintf(raw, "section,config,run,index,ns\n"); + } + + std::printf("interest_step_benchmark%s: runs %d, cycles %d, frames/cycle %zu\n", + options.smoke ? " (smoke: harness checks only, no timing claims)" : "", + options.runs, options.cycles, options.frames); + PrintEnvironment(); + MeasureTimer(options.smoke); + + const std::vector contexts = MakeContexts(); + const AttentionProfile profileOff = MakeProfile(contexts, 0.f); + const AttentionProfile profileOn = MakeProfile(contexts, 0.75f); + + static const Workload kMixed{"mixed", Spatial::Mixed, 0, false, false}; + static const Workload kShuffle{"shuffle", Spatial::Mixed, 0, false, true}; + static const Workload kDistributions[] = { + {"front", Spatial::Front, 0, false, false}, + {"surround", Spatial::Surround, 0, false, false}, + {"far80", Spatial::Far, 0, false, false}, + {"reject50", Spatial::Mixed, 50, false, false}, + kShuffle, + {"churn", Spatial::Mixed, 0, true, true}, + }; + static const Variant kCore{"core", false, 0.f, false}; + static const Variant kCoreTrace{"core+trace", false, 0.f, true}; + static const Variant kEventsBeta0{"events,beta0", true, 0.f, false}; + static const Variant kNovelty{"novelty", true, 0.75f, false}; + static const Variant kNoveltyTrace{"novelty+trace", true, 0.75f, true}; + + std::vector configs; + const auto add = [&](const Workload& w, const Variant& v, std::size_t n) { + Config c; + c.label = std::string(w.name) + "/" + v.name; + c.workload = &w; + c.variant = &v; + c.n = n; + // Same seed for every variant of a workload and size: only the variant differs. + const std::uint64_t seed = std::uint64_t{0xB0000000u} + std::uint64_t{n} * 131u + + std::uint64_t{static_cast(w.spatial)} * 7u + + std::uint64_t{static_cast(w.rejectPercent)} + + std::uint64_t{w.replace ? 3u : 0u}; + c.frames = MakeFrames(w, v.events, n, options.frames, seed); + configs.push_back(std::move(c)); + }; + for (const std::size_t n : {std::size_t{0}, std::size_t{16}, std::size_t{100}, std::size_t{500}}) { + for (const Variant* v : {&kCore, &kCoreTrace, &kEventsBeta0, &kNovelty, &kNoveltyTrace}) { + add(kMixed, *v, n); + } + } + for (const Workload& w : kDistributions) { + for (const std::size_t n : {std::size_t{100}, std::size_t{500}}) { + add(w, kCore, n); + add(w, kCoreTrace, n); + } + } + + // Characterize every configuration (untimed, traced) and record its fingerprint. + Workspace ws; + interest::Reserve(ws.scratch, 500); + interest::Reserve(ws.trace, 500); + const ObserverSnapshot observer = test::Observer(0); + std::size_t maxSamples = 0; + for (Config& c : configs) { + const AttentionProfile& profile = c.variant->beta > 0.f ? profileOn : profileOff; + c.checksum = Cycle(c, observer, profile, ws, true); + Require(Cycle(c, observer, profile, ws, false) == c.checksum, "trace-off cycle matches traced cycle"); + const std::size_t samplesPerRun = c.frames.size() * static_cast(options.cycles); + maxSamples = std::max(maxSamples, samplesPerRun); + c.perCall.reserve(samplesPerRun * static_cast(options.runs)); + } + CheckPairedConfigs(configs); + CheckReferencePairing(options); + + // Run-major order: every run measures every configuration once, so drift over the + // session shows up as run-to-run spread rather than as a configuration difference. + std::vector runSamples; + runSamples.reserve(maxSamples); + for (int run = 0; run < options.runs; ++run) { + for (Config& c : configs) { + const AttentionProfile& profile = c.variant->beta > 0.f ? profileOn : profileOff; + MeasureRun(c, observer, profile, ws, options, runSamples); + if (raw != nullptr) { + for (std::size_t i = 0; i < runSamples.size(); ++i) { + std::fprintf(raw, "single,%s/n%zu,%d,%zu,%lld\n", c.label.c_str(), c.n, run, i, + static_cast(runSamples[i])); + } + } + } + } + + if (!options.smoke) { + std::printf("\nWorkloads (untimed traced characterization of one %zu-frame cycle):\n", + options.frames); + std::printf(" %-22s %4s %7s %4s %4s %4s rejected entries by reason\n", "config", "n", + "elig%", "acq", "sw", "clr"); + for (const Config& c : configs) { + if (c.variant->trace) { + continue; // identical inputs and decisions to the untraced variant + } + std::printf(" %-22s %4zu %6.1f%% %4zu %4zu %4zu ", c.label.c_str(), c.n, + c.entries == 0 ? 0.0 + : 100.0 * static_cast(c.eligible) / + static_cast(c.entries), + c.acquires, c.switches, c.clears); + for (std::size_t e = 1; e < 16; ++e) { + if (c.rejected[e] != 0) { + std::printf(" %s %.1f%%", ShortReason(e), + 100.0 * static_cast(c.rejected[e]) / + static_cast(c.entries)); + } + } + std::printf("\n"); + } + + std::printf("\nPer-Step latency, single observer (ns; per-call samples include one timer" + " pair; batch = back-to-back mean without per-call timers):\n"); + std::printf(" %-22s %4s %7s %6s %6s %6s %6s %7s %13s %9s %15s %9s %12s\n", "config", "n", + "samples", "mean", "p50", "p95", "p99", "p99.9", "max", "batch", + "batch run range", "p50 runs", "cand/s"); + double baseBatch[2] = {0, 0}; // n == 0 batch mean for core / novelty, per-candidate deltas + for (const Config& c : configs) { + const Distribution d = Summarize(c.perCall); + std::vector batch = c.batchMean; + std::sort(batch.begin(), batch.end()); + const double batchMedian = batch[batch.size() / 2]; + const auto [p50Min, p50Max] = std::minmax_element(c.perCallRunP50.begin(), c.perCallRunP50.end()); + std::printf(" %-22s %4zu %7zu %6.0f %6lld %6lld %6lld %7lld %13lld %9.1f %7.1f-%-7.1f %4lld-%-4lld %12.3g\n", + c.label.c_str(), c.n, d.count, d.mean, static_cast(d.p50), + static_cast(d.p95), static_cast(d.p99), + static_cast(d.p999), static_cast(d.max), batchMedian, + batch.front(), batch.back(), static_cast(*p50Min), + static_cast(*p50Max), + static_cast(c.n) * 1e9 / batchMedian); + if (c.workload == &kMixed && c.n == 0 && c.variant == &kCore) { + baseBatch[0] = batchMedian; + } + if (c.workload == &kMixed && c.n == 0 && c.variant == &kNovelty) { + baseBatch[1] = batchMedian; + } + } + std::printf("\nMarginal batch cost per submitted candidate, mixed workload ((batch(n) -" + " batch(0)) / n, ns):\n"); + for (const Config& c : configs) { + if (c.workload != &kMixed || c.n == 0) { + continue; + } + std::vector batch = c.batchMean; + std::sort(batch.begin(), batch.end()); + const double base = c.variant->events ? baseBatch[1] : baseBatch[0]; + std::printf(" %-22s n=%3zu: %6.2f\n", c.label.c_str(), c.n, + (batch[batch.size() / 2] - base) / static_cast(c.n)); + } + } + + if (!options.smoke) { + // Paired per-run ratios of batch means: both configurations of a pair were measured + // in the same run, on input data the pairing checks proved identical apart from the + // compared factor. + const auto find = [&](const char* workload, const Variant& v, std::size_t n) -> const Config* { + for (const Config& c : configs) { + if (std::strcmp(c.workload->name, workload) == 0 && c.variant == &v && c.n == n) { + return &c; + } + } + return nullptr; + }; + const auto ratio = [&](const char* label, const Config* a, const Config* b) { + if (a == nullptr || b == nullptr) { + return; + } + std::vector r; + for (std::size_t run = 0; run < a->batchMean.size(); ++run) { + r.push_back(a->batchMean[run] / b->batchMean[run] - 1.0); + } + std::sort(r.begin(), r.end()); + std::printf(" %-34s n=%3zu: median %+6.1f%% runs %+6.1f%% .. %+6.1f%%\n", label, a->n, + 100.0 * r[r.size() / 2], 100.0 * r.front(), 100.0 * r.back()); + }; + std::printf("\nPaired per-run batch ratios (%d runs):\n", options.runs); + for (const std::size_t n : {std::size_t{16}, std::size_t{100}, std::size_t{500}}) { + ratio("trace: core+trace / core", find("mixed", kCoreTrace, n), find("mixed", kCore, n)); + ratio("events present, beta 0 / core", find("mixed", kEventsBeta0, n), find("mixed", kCore, n)); + ratio("beta .75 / beta 0 (same events)", find("mixed", kNovelty, n), find("mixed", kEventsBeta0, n)); + ratio("novelty / core", find("mixed", kNovelty, n), find("mixed", kCore, n)); + ratio("trace: novelty+trace / novelty", find("mixed", kNoveltyTrace, n), find("mixed", kNovelty, n)); + ratio("order only: shuffle / mixed", find("shuffle", kCore, n), find("mixed", kCore, n)); + ratio("replace+order: churn / mixed", find("churn", kCore, n), find("mixed", kCore, n)); + } + } + + MeasureIsolatedCosts(profileOff, configs, kMixed, kShuffle, kCore, options); + + // Reference workload variants: only events/beta/trace/submission order differ. + std::printf("\nReference workload: %zu observers x %zu candidates x 5 Hz = %zu Steps and %zu" + " submitted candidate evaluations per simulated second (input counts, not a" + " measurement). One sequential worker, synchronous ticks.\n", kRefObservers, + kRefCandidates, kRefObservers * kTicksPerSecond, + kRefObservers * kRefCandidates * kTicksPerSecond); + std::vector refs; + refs.reserve(4); + refs.emplace_back("ref/core", false, 0.f, false, false, contexts, options); + refs.emplace_back("ref/novelty", true, 0.75f, false, false, contexts, options); + refs.emplace_back("ref/novelty+trace", true, 0.75f, true, false, contexts, options); + refs.emplace_back("ref/core,shared-order", false, 0.f, false, true, contexts, options); + for (int run = 0; run < options.runs; ++run) { + for (ReferenceVariant& v : refs) { + v.MeasureRun(run, raw); + } + } + std::uint64_t refChecksums[4]{}; + for (std::size_t i = 0; i < refs.size(); ++i) { + refChecksums[i] = refs[i].Report(options); + } + // Order- and trace-independent decisions over identical world data. + Require(refChecksums[0] == refChecksums[3], "reference per-observer and shared order decide identically"); + Require(refChecksums[1] == refChecksums[2], "reference novelty trace on/off decide identically"); + if (!options.smoke) { + // Paired per-run ratios of mean tick time: both variants measured in the same run. + const auto ratios = [&](std::size_t a, std::size_t b) { + std::vector r; + for (std::size_t run = 0; run < refs[a].RunTickMeans().size(); ++run) { + r.push_back(refs[a].RunTickMeans()[run] / refs[b].RunTickMeans()[run] - 1.0); + } + std::sort(r.begin(), r.end()); + std::printf(" %-22s vs %-22s median %+6.1f%% runs %+6.1f%% .. %+6.1f%%\n", + refs[a].Name(), refs[b].Name(), 100.0 * r[r.size() / 2], + 100.0 * r.front(), 100.0 * r.back()); + }; + std::printf("\nReference paired per-run tick-time ratios (%d interleaved runs):\n", options.runs); + ratios(1, 0); + ratios(2, 1); + ratios(0, 3); + } + + PrintMemory(); + CheckUnderReserved(contexts); + if (raw != nullptr) { + std::fclose(raw); + } + PrintPeakProcessMemory(); + std::printf("harness checks: %s (statuses Ok, exact replay across warm-up/modes/runs, trace" + " on/off identical decisions, 0 allocations in measured regions)\n", + failed ? "FAILED" : "passed"); + return failed ? 1 : 0; +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..cb9d5f2 --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,122 @@ +# Step benchmark + +These measurements characterize the production kernel on synthetic snapshots. +They exclude perception, host scene traversal, snapshot gathering/copying, animation +and rendering. They are observations on one desktop, not a frame-budget guarantee +or a comparison against other attention-selection systems. + +## Reproduce + +Build Release as described in the README, then run: + +```sh +./build/interest_step_benchmark --runs 5 --cycles 128 --raw build/samples.csv +``` + +For Visual Studio, the executable is +`./build/Release/interest_step_benchmark.exe`. `--raw` is optional and writes +`section,config,run,index,ns` samples. `--smoke` uses tiny counts and validates the +harness only; its timings carry no performance claim. Debug and sanitizer builds +are correctness checks, not performance baselines. + +Use the same compiler, flags, workload and run counts for comparisons. Record CPU, +RAM, OS, power policy, background load and any affinity/frequency controls. Inspect +all runs and tails, not just a favorable median. Deterministic generation and +pairing checks make the input comparisons reproducible; wall-clock timings vary. + +## Recorded environment + +The following results were measured on 2026-10-16 using the attention implementation +and benchmark harness in this repository. + +| Component | Recorded configuration | +|---|---| +| CPU | AMD Ryzen 7 5700X, 8 cores / 16 threads, 3.4 GHz base; 512 KiB L2/core, 32 MiB L3 | +| Memory / OS | 32 GiB RAM; Windows 11 Pro 10.0.26200 | +| Power / scheduling | AMD Ryzen High Performance; ordinary desktop with other applications running; no affinity, priority adjustment or frequency lock | +| Compiler | MSVC 19.38.33145, Visual Studio 2022 toolset 14.38.33130, x64 | +| Build tools | CMake 3.31.6-msvc6, Ninja 1.12.1 | +| Flags | `/O2 /Ob2 /DNDEBUG`, `/W4 /WX /permissive- /utf-8`; no LTO/IPO | + +Three independent processes each ran five repetitions of the matrix with strictly +paired variant inputs. The tables below summarize the recorded output of those +processes; the harness checks input pairing as part of each run. + +## Method and workloads + +Inputs are generated and every workspace/sample buffer reserved before timing. +The harness invokes the real out-of-line `Step()` and checks allocation counts, +successful statuses and replay fingerprints. Event variants differ only in event +fields; fixed/shuffled variants submit the same per-frame snapshots. These +relationships and trace-on/off decision equivalence are checked in smoke mode too. + +The single-observer matrix has 44 configurations: mixed geometry with 0, 16, 100 +and 500 candidates across core, trace, beta-zero events and novelty variants; +100/500-candidate front, surround, far80, reject50, shuffled and churn workloads. +The mixed workload uses one-third Point/Bearing/NonSpatial, all eligible, fixed +IDs/order and small jitter. Points span 360 degrees and distances 1–38 with cutoff +40. Weights are 1/1/1, full range 10, angle floor .2, sharpness 2, acquire/release +.5/.4, switch/override margins .08/.2 and dwell 0. Two contexts use eight affinity +tags. Novelty variants use beta .75; the source defines all remaining inputs. + +Each configuration uses a deterministic 64-frame sequence at explicit 5 Hz, five +runs and 128 cycles per run/mode: 40,960 per-call samples. Each run/configuration +has an untimed warm-up cycle. Per-call samples include one timer pair. Percentiles +use nearest rank over pooled samples. Batch timing brackets 64 consecutive steps, +including a decision fingerprint fold, with reset outside timing; the reported +batch statistic is the median of five run means. Run-major ordering exposes drift +as run spread. The observed timer granularity was 100 ns and empty regions averaged +18.8–20.6 ns across processes; batch figures are more useful at small counts. + +Core mixed-workload **batch nanoseconds per Step**, trace off and no events: + +| Submitted candidates | Process 1 | Process 2 | Process 3 | +|---:|---:|---:|---:| +| 0 | 318.9 | 303.2 | 294.6 | +| 16 | 1,248.9 | 1,252.1 | 1,249.3 | +| 100 | 6,721.4 | 6,365.8 | 6,517.1 | +| 500 | 35,371.6 | 34,634.9 | 35,154.7 | + +The reference workload is 100 observers × 500 candidates × 5 Hz: 500 steps and +250,000 submitted candidate evaluations per simulated second. One sequential +worker processes synchronous 100-observer ticks, 50 ticks per run, five runs. +Its world is 70% moving Points, 15% Bearing and 15% NonSpatial; 56.5% of submitted +entries are eligible. Host gathering and copying remain outside the timer. + +Core reference kernel time per simulated second was **24.013 / 23.479 / 22.817 ms** +across the three processes. In process 1, a synchronous 100-Step tick averaged +4.8025 ms; individual Step mean/p50/p95/p99 were 45.965/42.9/61.6/69.6 microseconds. +This is work accumulated at the stated simulation rate, not a frame time. + +Order materially affects CPU cost. Strictly paired reshuffling added 27–28% at +100 mixed candidates and 37–43% at 500 across these processes. In the reference +workload, per-observer submission order cost 45–50% more than shared order despite +identical decisions. Copy/sort/search isolation probes are diagnostic estimates, +not profiler-confirmed causal attribution. Re-run with your actual distributions, +rejections, contexts, event density and tracing needs. + +## Allocation and memory evidence + +Each recorded Release process observed zero allocations across 3,604,480 measured +single-observer steps and 200,000 reference steps, after preparation. The dedicated +allocation regression also exercises 400 successful prepared calls plus four +step-level rejection paths. This does not prohibit setup allocation in `Reserve()` +or host-side allocation. + +On the measured MSVC x64 build, `ObserverMemory` is 32 bytes. Capacity 500 requires +4,000 bytes of scratch ID storage and optionally 48,000 bytes of trace entries, +excluding vector objects and allocator overhead. Input arrays belong to the host. +Scratch/trace may be reused sequentially between observers; only observer memory +must persist. These sizes are implementation/platform observations, not ABI promises. +Peak benchmark-process memory includes pre-generated workloads and is not kernel +retained memory. + +## Interpretation limits + +Geometric gates attenuate novelty; the synthetic front/behind event test captures +40/40 labelled front events and 0/40 labelled important behind events under its +chosen profile. Saturation can erase a challenger's otherwise sufficient advantage. +Beta, half-life, margins and update cadence alter capture and retention. These +scenarios document limitations rather than validate real gameplay, perception or +human attention. Replay equality is tested within a build/platform; cross-platform +bit-exact output, ARM and other architectures remain unverified. diff --git a/examples/basic.cpp b/examples/basic.cpp new file mode 100644 index 0000000..758ab0b --- /dev/null +++ b/examples/basic.cpp @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +#include "interest/attention.hpp" + +#include +#include + +int main() +{ + // The profile borrows this storage. Keep it alive through every Step call. + std::array contexts{}; + contexts[0].affinityTags = 1; + contexts[0].tagAffinity[0] = 0.8f; + + interest::AttentionProfile profile{}; + profile.contexts = contexts; + profile.fullRange = 10.f; + profile.maxRange = 30.f; + profile.acquireThreshold = 0.5f; + profile.releaseThreshold = 0.35f; + profile.switchMargin = 0.1f; + profile.overrideMargin = 0.3f; + profile.minDwellMs = 400; + profile.noveltyBeta = 0.25f; + profile.noveltyHalfLifeMs = 1000; + if (interest::ValidateProfile(profile) != interest::StepStatus::Ok) { + return 1; + } + + interest::ObserverMemory memory{}; // one per observer; persist between calls + interest::StepScratch scratch{}; + interest::DecisionTrace trace{}; // optional + interest::Reserve(scratch, 8); // setup may allocate; Step will not + interest::Reserve(trace, 8); + + interest::ObserverSnapshot observer{}; + observer.id = 1; + observer.referenceForward = {1.f, 0.f, 0.f}; // body/travel baseline + observer.context = 0; + + std::array candidates{}; + auto& candidate = candidates[0]; + candidate.id = 42; + candidate.position = {5.0, 0.0, 0.0}; + candidate.attraction = 0.8f; + candidate.salience = 0.7f; + candidate.tags = 1; + candidate.eventTimeMs = 1000; + candidate.eventStrength = 1.f; + + // Explicit monotonic host time; the event timestamp stays unchanged on replay. + for (const interest::TimeMs now : {1000, 1200}) { + interest::Decision decision{}; + const auto status = interest::Step(observer, candidates, profile, now, + memory, scratch, decision, &trace); + if (status != interest::StepStatus::Ok) { + std::fprintf(stderr, "Step failed: %s\n", interest::ToString(status)); + return 1; + } + switch (decision.kind) { + case interest::DecisionKind::Acquire: + case interest::DecisionKind::Keep: + case interest::DecisionKind::Switch: + // The host resolves the ID and drives gameplay or presentation. + std::printf("%s %llu (%s)\n", interest::ToString(decision.kind), + static_cast(decision.selected), + interest::ToString(decision.reason)); + break; + case interest::DecisionKind::Clear: + case interest::DecisionKind::None: + std::puts("No attention target"); + break; + } + if (decision.selected != 42 || + decision.kind != (now == 1000 ? interest::DecisionKind::Acquire + : interest::DecisionKind::Keep)) { + return 1; + } + } + return 0; +} diff --git a/examples/consumer/CMakeLists.txt b/examples/consumer/CMakeLists.txt new file mode 100644 index 0000000..8c062ef --- /dev/null +++ b/examples/consumer/CMakeLists.txt @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 PullsarDev +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.20) +project(InterestConsumer LANGUAGES CXX) + +add_subdirectory(../.. interest-engine) +if(INTEREST_BUILD_TESTS OR INTEREST_BUILD_BENCHMARKS OR INTEREST_BUILD_EXAMPLES) + message(FATAL_ERROR "Embedded library utilities must default to OFF") +endif() + +add_executable(interest_consumer ../basic.cpp) +target_link_libraries(interest_consumer PRIVATE InterestEngine::Core) + +enable_testing() +add_test(NAME interest_consumer COMMAND interest_consumer) diff --git a/include/interest/attention.hpp b/include/interest/attention.hpp new file mode 100644 index 0000000..7078466 --- /dev/null +++ b/include/interest/attention.hpp @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// Attention-selection kernel; see README.md for an integration overview. +// +// The host supplies plain value snapshots, a profile, explicit time and caller-owned +// memory/scratch; Step() returns one decision and, optionally, a trace of the same +// computation. Scoring is stateless. The temporal policy then decides +// whether to keep, replace or release the current target: +// - No current target: acquire the leading candidate if it reaches acquireThreshold. +// - Current target absent or ineligible: replace it with the leading candidate if +// that reaches acquireThreshold, else clear. Dwell never protects it. +// - During minimum dwell: keep the current target, unless a challenger reaches +// acquireThreshold and leads it by overrideMargin. +// - After dwell: release it below releaseThreshold (falling back to a challenger that +// reaches acquireThreshold), or switch to a challenger that reaches acquireThreshold +// and leads it by switchMargin. Otherwise keep it. +// Dwell is measured in host time and checked only at steps, so its resolution is the +// host's update interval. Optional spatially gated timestamped novelty is applied +// before that policy. No candidate or event history is retained by the kernel. +// +// Standard library only. Step is noexcept and uses no RTTI, global state, hidden clock or RNG. +// +// Capacity contract: Step never allocates. Before stepping, the caller prepares its +// StepScratch (and DecisionTrace, if one is passed) with Reserve() for the largest +// candidate count it will submit. A step with more candidates than the prepared capacity +// returns InsufficientCapacity and mutates nothing. + +#include +#include +#include +#include +#include + +namespace interest { + +using ObserverId = std::uint64_t; +using CandidateId = std::uint64_t; // 0 = no candidate +using TimeMs = std::int64_t; // host-supplied, monotonic non-decreasing milliseconds +using TagMask = std::uint64_t; // host-defined tag bit mapping +using ContextId = std::uint8_t; // index into AttentionProfile::contexts + +inline constexpr CandidateId kNoCandidate = 0; +inline constexpr std::size_t kTagCount = 64; // one affinity entry per TagMask bit + +struct Vec3d { double x{}, y{}, z{}; }; +struct Vec3f { float x{}, y{}, z{}; }; + +enum class SpatialKind : std::uint8_t { Point, Bearing, NonSpatial }; + +struct ObserverSnapshot { + ObserverId id{}; // host bookkeeping only; Step never reads it + Vec3d position{}; + // Host-owned body/travel/attention baseline; any finite nonzero length (normalized + // internally). Must NOT be the head/eye gaze driven by the selected target, which + // would let the incumbent reinforce its own angle score. + Vec3f referenceForward{1.f, 0.f, 0.f}; + ContextId context{}; +}; + +struct CandidateSnapshot { + CandidateId id{}; // nonzero, unique within one Step call + SpatialKind spatial{SpatialKind::Point}; + Vec3d position{}; // Point only + Vec3f bearing{}; // Bearing only: world-space direction, any finite nonzero length + float attraction{0.5f}; // [0,1] relatively persistent inherent appeal + float salience{0.5f}; // [0,1] momentary perceptual prominence + float perceptionStrength{1.f}; // [0,1]; 0 = not perceived + float maxDistance{0.f}; // Point only; >= 0, 0 = profile maxRange + TagMask tags{}; + bool enabled{true}; + // Latest host-authored event: immutable time/strength for an unchanged event. + // Strength 0 means absent (time ignored). Positive strength requires time <= now. + // Resubmit while relevant; omission removes the term. No event history is retained. + TimeMs eventTimeMs{}; + float eventStrength{}; // finite [0,1], validated even when noveltyBeta == 0 +}; + +// Per-context eligibility and preference. Selected by ObserverSnapshot::context. +struct ContextPreference { + TagMask requiredTags{}; // candidate must carry all of these + TagMask excludedTags{}; // candidate must carry none of these + TagMask affinityTags{}; // tag bits whose tagAffinity entry applies + std::array tagAffinity{}; // [0,1], indexed by tag bit + // [0,1] affinity for a candidate carrying no tag in affinityTags. This covers both + // untagged candidates and candidates whose tags all lack a configured affinity. + float fallbackAffinity{0.5f}; +}; + +struct AttentionProfile { + // Weighted-mean preference weights: finite, >= 0, with a positive sum. + float attractionWeight{1.f}; + float salienceWeight{1.f}; + float affinityWeight{1.f}; + // Point distance gate in host distance units. Falloff runs from fullRange to the + // cutoff (candidate maxDistance, or maxRange when that is 0); fullRange >= cutoff + // means no falloff before the cutoff. maxRange has no default and must be set > 0. + float fullRange{0.f}; // >= 0 + float maxRange{0.f}; // > 0 + float angleFloor{0.f}; // [0,1] angle gate for a stimulus directly behind + float angleSharpness{1.f}; // >= 0 + float acquireThreshold{0.f}; // (0,1]; no default, must be set + // Temporal policy. The thresholds and margins have no defaults and must be set. + // (0, acquireThreshold]; once dwell ends, a current target scoring below this is + // released. Equal to acquireThreshold means no hysteresis band. + float releaseThreshold{0.f}; + // (0,1]; lead over the current target's score that a challenger needs to replace it. + // Must be positive: with no margin, two exactly tied candidates would swap every step. + float switchMargin{0.f}; + // [switchMargin,1]; lead that lets a challenger replace the target during dwell. + float overrideMargin{0.f}; + // >= 0; after a target is selected, it is kept this long unless it becomes ineligible + // or a challenger meets overrideMargin. 0 means no dwell. + TimeMs minDwellMs{0}; + // Caller-owned; must outlive the Step call. At least one entry. + std::span contexts; + // Novelty: effective = min(1, rawScore + beta * gate * eventNovelty). + // eventNovelty = eventStrength * 2^(-age / halfLife), from explicit host time. + float noveltyBeta{0.f}; // [0,1]; 0 disables the contribution, not validation + TimeMs noveltyHalfLifeMs{1000}; // strictly positive +}; + +enum class StepStatus : std::uint8_t { + Ok, + TimeWentBackwards, + InvalidObserver, + InvalidProfile, + InsufficientCapacity, // more candidates than the prepared scratch or trace capacity +}; + +enum class DecisionKind : std::uint8_t { + None, // nothing selected, nothing was selected before + Acquire, // nothing was selected before; a candidate is selected now + Keep, // the previously selected candidate is selected again + Switch, // a different candidate than the previous one is selected + Clear, // a candidate was selected before; nothing is selected now +}; + +// Why the policy reached its decision. The "current" target is the one selected by the +// previous successful step (Decision::previous). A challenger is the leading eligible +// candidate other than the current target. Every Keep reason except HighestScore marks +// a step where a stateless argmax of effective scores would have chosen differently. +enum class DecisionReason : std::uint8_t { + NoEligibleCandidate, // None: no current target and every candidate was rejected (or none) + BelowAcquireThreshold, // None: no current target; the leading candidate is below acquireThreshold + HighestScore, // Acquire, or Keep: the selected candidate leads and reaches acquireThreshold + CurrentInvalid, // Switch/Clear: the current target is absent or ineligible, even during dwell + BelowReleaseThreshold, // Switch/Clear: after dwell, the current target is below releaseThreshold + BetterCandidate, // Switch: after dwell, a challenger reaches acquireThreshold and leads by switchMargin + DwellOverride, // Switch: during dwell, a challenger reaches acquireThreshold and leads by overrideMargin + MinimumDwell, // Keep: dwell defers a release or switch that would otherwise happen + InsufficientAdvantage, // Keep: a challenger outranks the current target but does not qualify to replace it + AboveReleaseThreshold, // Keep: the current target leads, below acquireThreshold but not below releaseThreshold +}; + +struct Decision { + DecisionKind kind{DecisionKind::None}; + DecisionReason reason{DecisionReason::NoEligibleCandidate}; + CandidateId selected{}; // 0 for None/Clear + CandidateId previous{}; // selection of the previous successful step, 0 if none + float rawScore{}; // selected candidate's raw score; 0 when nothing is selected + float effectiveScore{}; // [0,1] saturated raw + novelty score used by the temporal policy +}; + +// Caller-owned per-observer state. Changes only on a successful Step. Start from a +// value-initialized memory or ResetMemory() and leave updates to Step, which keeps +// focusStartTime <= lastStepTime while current != 0; when there is no current target, +// focusStartTime is reset to 0 (including if lastStepTime is negative). Step does +// not validate edited memory. The one edited state it defines is a focusStartTime later +// than `now`: the current target then has no dwell. +struct ObserverMemory { + CandidateId current{}; // last selection, 0 if none + TimeMs focusStartTime{}; // step time at which `current` was selected; 0 if none + TimeMs lastStepTime{}; + bool hasStepped{false}; + // Temporal state only. Novelty is derived from the supplied event's absolute + // age; ResetMemory does not make an old event new. No admission/eviction occurs. +}; + +// Caller-owned reusable workspace; contents are unspecified between calls. Prepare it +// with Reserve(); Step only uses the reserved capacity and never grows it. Copying a +// StepScratch does not preserve its capacity. +struct StepScratch { + std::vector sortedIds; +}; + +// First failing check wins in the order below, except InvalidEvent (appended to keep +// existing enum values) is checked between InvalidGeometry and Disabled. +enum class Eligibility : std::uint8_t { + Eligible, + InvalidId, // id == 0 + DuplicateId, // id occurs more than once in this Step's candidates (every occurrence) + InvalidValue, // non-finite/out-of-range scalar or unknown SpatialKind + InvalidGeometry, // non-finite Point position, or zero/non-finite Bearing + Disabled, + NotPerceived, // perceptionStrength == 0 + MissingRequiredTag, + ExcludedTag, + OutOfRange, // Point farther than its cutoff distance + InvalidEvent, // non-finite/out-of-range strength or positive-strength future time +}; + +// One evaluated candidate. Factors are the values used for selection; those the +// evaluation did not reach are 0. +struct CandidateTrace { + CandidateId id{}; + Eligibility eligibility{}; + float attraction{}; // input copy + float salience{}; // input copy + float perceptionStrength{}; // input copy + double distance{}; // Point only, once computed + float distanceGate{}; + float angleGate{}; + float gate{}; // distanceGate * angleGate * perceptionStrength + float affinity{}; // context affinity + float preference{}; // weighted mean of attraction, salience, affinity + float rawScore{}; // gate * preference, base score + float effectiveScore{}; // min(1, rawScore + noveltyContribution), as compared + TimeMs eventTimeMs{}; // input copy, ignored if eventStrength == 0 + float eventStrength{}; // input copy, including rejected values + double eventNovelty{}; // strength * 2^(-age / halfLife), [0,1]; absent => 0 + double noveltyContribution{}; // beta * gate * eventNovelty, before saturation +}; + +// Optional caller-owned explanation of one successful Step; written only when Ok. +// Prepare it with Reserve() like StepScratch. +struct DecisionTrace { + std::vector candidates; // one per input candidate, in input order + CandidateId leading{}; // highest eligible effective score, ties to lower ID; 0 if none + float leadingScore{}; + // Temporal policy inputs, as compared. The current target is Decision::previous. + bool currentEligible{}; // the current target is present and eligible + float currentScore{}; // its effective score; 0 when absent or ineligible + CandidateId challenger{}; // leading eligible candidate other than the current target; 0 if none + float challengerScore{}; + // Dwell left for the current target at this step; 0 when expired or there is none. + // An ineligible current target is not protected even when this is positive. + TimeMs dwellRemainingMs{}; + float acquireThreshold{}; // profile values the scores were compared against + float releaseThreshold{}; + float switchMargin{}; + float overrideMargin{}; +}; + +// Evaluates one observer. Validation order: profile, observer, time, capacity. On any +// status other than Ok, memory, output, scratch and trace are left untouched. Candidate-level problems +// never fail the step; they reject only that candidate (see Eligibility). +// Ties between equal effective scores go to the lower candidate ID, and the temporal policy +// compares only scores and IDs, so the result does not depend on candidate order. An +// exact tie never replaces the current target, because switchMargin is positive. +StepStatus Step(const ObserverSnapshot& observer, + std::span candidates, + const AttentionProfile& profile, + TimeMs now, + ObserverMemory& memory, + StepScratch& scratch, + Decision& output, + DecisionTrace* trace = nullptr) noexcept; + +void ResetMemory(ObserverMemory& memory) noexcept; + +// Prepare caller-owned buffers for up to `candidateCapacity` candidates per Step. These +// allocate (and, where exceptions are enabled, may throw std::bad_alloc), so call them +// outside the per-step path. Capacity never shrinks. +void Reserve(StepScratch& scratch, std::size_t candidateCapacity); +void Reserve(DecisionTrace& trace, std::size_t candidateCapacity); + +// Returns Ok or InvalidProfile; Step applies the same check. +StepStatus ValidateProfile(const AttentionProfile& profile) noexcept; + +const char* ToString(StepStatus value) noexcept; +const char* ToString(DecisionKind value) noexcept; +const char* ToString(DecisionReason value) noexcept; +const char* ToString(Eligibility value) noexcept; + +} // namespace interest diff --git a/include/interest/version.hpp b/include/interest/version.hpp new file mode 100644 index 0000000..6de3eb1 --- /dev/null +++ b/include/interest/version.hpp @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +namespace interest { + +// Returns the kernel version as a static, null-terminated "major.minor.patch" string. +const char* VersionString() noexcept; + +} // namespace interest diff --git a/src/step.cpp b/src/step.cpp new file mode 100644 index 0000000..3732779 --- /dev/null +++ b/src/step.cpp @@ -0,0 +1,587 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +#include "interest/attention.hpp" + +#include +#include +#include + +namespace interest { +namespace { + +struct Vec3 { double x, y, z; }; + +// Observer quantities shared by every candidate in one step. +struct ObserverFrame { + Vec3 position; + Vec3 forward; // unit length +}; + +bool IsUnitInterval(float value) noexcept +{ + return std::isfinite(value) && value >= 0.f && value <= 1.f; +} + +bool IsFiniteNonNegative(float value) noexcept +{ + return std::isfinite(value) && value >= 0.f; +} + +bool IsFinite(const Vec3d& v) noexcept +{ + return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); +} + +double Dot(const Vec3& a, const Vec3& b) noexcept +{ + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +// Rejects zero-length and non-finite vectors instead of guessing a direction. +bool TryNormalize(const Vec3f& v, Vec3& out) noexcept +{ + if (!std::isfinite(v.x) || !std::isfinite(v.y) || !std::isfinite(v.z)) { + return false; + } + const Vec3 d{static_cast(v.x), static_cast(v.y), static_cast(v.z)}; + const double length = std::sqrt(Dot(d, d)); + if (!(length > 0.0)) { + return false; + } + out = Vec3{d.x / length, d.y / length, d.z / length}; + return true; +} + +bool IsValidSpatialKind(SpatialKind kind) noexcept +{ + return kind == SpatialKind::Point || kind == SpatialKind::Bearing || + kind == SpatialKind::NonSpatial; +} + +bool IsValidContext(const ContextPreference& context) noexcept +{ + if ((context.requiredTags & context.excludedTags) != 0) { + return false; // no candidate could ever be eligible + } + if (!IsUnitInterval(context.fallbackAffinity)) { + return false; + } + return std::all_of(context.tagAffinity.begin(), context.tagAffinity.end(), IsUnitInterval); +} + +bool TryMakeObserverFrame(const ObserverSnapshot& observer, const AttentionProfile& profile, + ObserverFrame& out) noexcept +{ + if (!IsFinite(observer.position) || + static_cast(observer.context) >= profile.contexts.size()) { + return false; + } + Vec3 forward{}; + if (!TryNormalize(observer.referenceForward, forward)) { + return false; + } + out = ObserverFrame{Vec3{observer.position.x, observer.position.y, observer.position.z}, + forward}; + return true; +} + +double SmoothStep(double edge0, double edge1, double x) noexcept +{ + const double t = std::clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +double AngleGate(double cosPhi, const AttentionProfile& profile) noexcept +{ + // Rounding can push a dot product of unit vectors just outside [-1,1]; pow() of a + // negative base would then be NaN. + const double base = (1.0 + std::clamp(cosPhi, -1.0, 1.0)) * 0.5; + const double angleFloor = static_cast(profile.angleFloor); + return angleFloor + + (1.0 - angleFloor) * std::pow(base, static_cast(profile.angleSharpness)); +} + +float ContextAffinity(TagMask tags, const ContextPreference& context) noexcept +{ + TagMask matched = tags & context.affinityTags; + if (matched == 0) { + return context.fallbackAffinity; + } + float affinity = 0.f; + while (matched != 0) { + const auto bit = static_cast(std::countr_zero(matched)); + if (context.tagAffinity[bit] > affinity) { + affinity = context.tagAffinity[bit]; + } + matched &= matched - 1; // clear the lowest set bit + } + return affinity; +} + +bool HasInvalidValue(const CandidateSnapshot& candidate) noexcept +{ + if (!IsUnitInterval(candidate.attraction) || !IsUnitInterval(candidate.salience) || + !IsUnitInterval(candidate.perceptionStrength) || + !IsValidSpatialKind(candidate.spatial)) { + return true; + } + return candidate.spatial == SpatialKind::Point && + !IsFiniteNonNegative(candidate.maxDistance); +} + +// Fills `out` with the eligibility and score of one candidate. This is the only +// scoring path; the trace records exactly these values. +void EvaluateCandidate(const CandidateSnapshot& candidate, bool duplicate, + const ObserverFrame& observer, const AttentionProfile& profile, + const ContextPreference& context, TimeMs now, CandidateTrace& out) noexcept +{ + out = CandidateTrace{}; + out.id = candidate.id; + out.attraction = candidate.attraction; + out.salience = candidate.salience; + out.perceptionStrength = candidate.perceptionStrength; + out.eventTimeMs = candidate.eventTimeMs; + out.eventStrength = candidate.eventStrength; + + const auto reject = [&out](Eligibility reason) noexcept { out.eligibility = reason; }; + + if (candidate.id == kNoCandidate) { + return reject(Eligibility::InvalidId); + } + if (duplicate) { + return reject(Eligibility::DuplicateId); + } + if (HasInvalidValue(candidate)) { + return reject(Eligibility::InvalidValue); + } + Vec3 bearing{}; + const bool geometryValid = + candidate.spatial == SpatialKind::Point ? IsFinite(candidate.position) + : candidate.spatial == SpatialKind::Bearing ? TryNormalize(candidate.bearing, bearing) + : true; + if (!geometryValid) { + return reject(Eligibility::InvalidGeometry); + } + if (!IsUnitInterval(candidate.eventStrength) || + (candidate.eventStrength > 0.f && candidate.eventTimeMs > now)) { + return reject(Eligibility::InvalidEvent); + } + if (!candidate.enabled) { + return reject(Eligibility::Disabled); + } + if (candidate.perceptionStrength == 0.f) { + return reject(Eligibility::NotPerceived); + } + if ((candidate.tags & context.requiredTags) != context.requiredTags) { + return reject(Eligibility::MissingRequiredTag); + } + if ((candidate.tags & context.excludedTags) != 0) { + return reject(Eligibility::ExcludedTag); + } + + double distanceGate = 1.0; + double cosPhi = 1.0; // NonSpatial: no angle penalty + if (candidate.spatial == SpatialKind::Point) { + const Vec3 offset{candidate.position.x - observer.position.x, + candidate.position.y - observer.position.y, + candidate.position.z - observer.position.z}; + const double distance = std::sqrt(Dot(offset, offset)); + out.distance = distance; + const double cutoff = static_cast( + candidate.maxDistance > 0.f ? candidate.maxDistance : profile.maxRange); + if (distance > cutoff) { // an overflowed (infinite) distance is out of range too + return reject(Eligibility::OutOfRange); + } + const double fullRange = static_cast(profile.fullRange); + if (fullRange < cutoff) { + distanceGate = 1.0 - SmoothStep(fullRange, cutoff, distance); + } + if (distance > 0.0) { // a coincident point keeps cosPhi = 1 + cosPhi = Dot(offset, observer.forward) / distance; + } + } else if (candidate.spatial == SpatialKind::Bearing) { + cosPhi = Dot(bearing, observer.forward); + } + + const double angleGate = AngleGate(cosPhi, profile); + const double gate = + distanceGate * angleGate * static_cast(candidate.perceptionStrength); + + const float affinity = ContextAffinity(candidate.tags, context); + const double wAttraction = static_cast(profile.attractionWeight); + const double wSalience = static_cast(profile.salienceWeight); + const double wAffinity = static_cast(profile.affinityWeight); + const double preference = + (wAttraction * static_cast(candidate.attraction) + + wSalience * static_cast(candidate.salience) + + wAffinity * static_cast(affinity)) / + (wAttraction + wSalience + wAffinity); + + out.eligibility = Eligibility::Eligible; + out.distanceGate = static_cast(distanceGate); + out.angleGate = static_cast(angleGate); + out.gate = static_cast(gate); + out.affinity = affinity; + out.preference = static_cast(preference); + // Selection compares this float, so the trace shows exactly the compared value. + out.rawScore = static_cast(gate * preference); +} + +DecisionKind ClassifyDecision(CandidateId previous, CandidateId selected) noexcept +{ + if (selected == kNoCandidate) { + return previous == kNoCandidate ? DecisionKind::None : DecisionKind::Clear; + } + if (previous == kNoCandidate) { + return DecisionKind::Acquire; + } + return previous == selected ? DecisionKind::Keep : DecisionKind::Switch; +} + +// A candidate and its effective score; id 0 means none. +struct Ranked { + CandidateId id{}; + float score{}; +}; + +// Higher score first, then lower ID. Eligible IDs are unique and nonzero, so this is a +// strict order and the winner does not depend on candidate order. +bool Outranks(CandidateId id, float score, const Ranked& other) noexcept +{ + return other.id == kNoCandidate || score > other.score || + (score == other.score && id < other.id); +} + +// What the temporal policy compares, gathered in the single evaluation pass. +struct PolicyInput { + Ranked leading; // best eligible candidate + Ranked challenger; // best eligible candidate other than the current target + bool currentEligible = false; + float currentScore = 0.f; + TimeMs dwellRemainingMs = 0; +}; + +struct Choice { + CandidateId selected = kNoCandidate; + float score = 0.f; + DecisionReason reason = DecisionReason::NoEligibleCandidate; +}; + +TimeMs DwellRemaining(TimeMs now, TimeMs focusStartTime, TimeMs minDwellMs) noexcept +{ + // Memory written by Step always has focusStartTime <= now. A later focus start can + // only come from caller-edited memory; it gets no dwell, rather than a wrapped elapsed + // time that could look short. + if (now < focusStartTime) { + return 0; + } + // The unsigned difference is now the exact elapsed time across the whole int64 range, + // where a signed subtraction could overflow. + const std::uint64_t elapsed = + static_cast(now) - static_cast(focusStartTime); + const auto dwell = static_cast(minDwellMs); + return elapsed < dwell ? static_cast(dwell - elapsed) : 0; +} + +// Whether the challenger leads the current score by at least `margin`. The difference is +// taken in double, so with a positive margin an exact tie never passes, and a challenger +// that passes always scores strictly higher than the current target. +bool LeadsBy(const Ranked& challenger, float currentScore, float margin) noexcept +{ + return static_cast(challenger.score) - static_cast(currentScore) >= + static_cast(margin); +} + +// The ordered temporal policy. It only ever selects the leading candidate, keeps +// the current target, or selects nothing. +Choice ApplyTemporalPolicy(CandidateId current, const PolicyInput& in, + const AttentionProfile& profile) noexcept +{ + const bool challengerQualifies = in.challenger.id != kNoCandidate && + in.challenger.score >= profile.acquireThreshold; + const auto take = [](const Ranked& target, DecisionReason reason) noexcept { + return Choice{target.id, target.score, reason}; + }; + const auto keep = [&](DecisionReason reason) noexcept { + return Choice{current, in.currentScore, reason}; + }; + // Drops the current target: a qualifying challenger replaces it, or nothing does. + const auto fallBack = [&](DecisionReason reason) noexcept { + return challengerQualifies ? take(in.challenger, reason) + : Choice{kNoCandidate, 0.f, reason}; + }; + + if (current == kNoCandidate) { + if (in.leading.id == kNoCandidate) { + return Choice{kNoCandidate, 0.f, DecisionReason::NoEligibleCandidate}; + } + if (in.leading.score < profile.acquireThreshold) { + return Choice{kNoCandidate, 0.f, DecisionReason::BelowAcquireThreshold}; + } + return take(in.leading, DecisionReason::HighestScore); + } + if (!in.currentEligible) { + return fallBack(DecisionReason::CurrentInvalid); // dwell never protects it + } + + // What would happen without dwell. + const bool belowRelease = in.currentScore < profile.releaseThreshold; + const bool betterChallenger = + challengerQualifies && LeadsBy(in.challenger, in.currentScore, profile.switchMargin); + if (!belowRelease && !betterChallenger) { + if (in.leading.id != current) { + return keep(DecisionReason::InsufficientAdvantage); + } + return keep(in.currentScore < profile.acquireThreshold + ? DecisionReason::AboveReleaseThreshold + : DecisionReason::HighestScore); + } + if (in.dwellRemainingMs > 0) { + // overrideMargin >= switchMargin, so an override is always also a better challenger. + if (challengerQualifies && + LeadsBy(in.challenger, in.currentScore, profile.overrideMargin)) { + return take(in.challenger, DecisionReason::DwellOverride); + } + return keep(DecisionReason::MinimumDwell); + } + return belowRelease ? fallBack(DecisionReason::BelowReleaseThreshold) + : take(in.challenger, DecisionReason::BetterCandidate); +} + +} // namespace + +StepStatus ValidateProfile(const AttentionProfile& profile) noexcept +{ + const bool weightsValid = IsFiniteNonNegative(profile.attractionWeight) && + IsFiniteNonNegative(profile.salienceWeight) && + IsFiniteNonNegative(profile.affinityWeight) && + static_cast(profile.attractionWeight) + + static_cast(profile.salienceWeight) + + static_cast(profile.affinityWeight) > + 0.0; + const bool rangesValid = IsFiniteNonNegative(profile.fullRange) && + std::isfinite(profile.maxRange) && profile.maxRange > 0.f; + const bool angleValid = IsUnitInterval(profile.angleFloor) && + IsFiniteNonNegative(profile.angleSharpness); + // A zero threshold would let a zero-score candidate become a target. + const bool thresholdValid = IsUnitInterval(profile.acquireThreshold) && + profile.acquireThreshold > 0.f; + // Likewise a zero release threshold would keep a zero-score target forever. Equal + // thresholds are allowed: no hysteresis band. + const bool releaseValid = std::isfinite(profile.releaseThreshold) && + profile.releaseThreshold > 0.f && + profile.releaseThreshold <= profile.acquireThreshold; + const bool marginsValid = IsUnitInterval(profile.switchMargin) && + profile.switchMargin > 0.f && + IsUnitInterval(profile.overrideMargin) && + profile.overrideMargin >= profile.switchMargin; + if (!weightsValid || !rangesValid || !angleValid || !thresholdValid || !releaseValid || + !marginsValid || profile.minDwellMs < 0 || !IsUnitInterval(profile.noveltyBeta) || + profile.noveltyHalfLifeMs <= 0 || profile.contexts.empty() || + !std::all_of(profile.contexts.begin(), profile.contexts.end(), IsValidContext)) { + return StepStatus::InvalidProfile; + } + return StepStatus::Ok; +} + +StepStatus Step(const ObserverSnapshot& observer, + std::span candidates, + const AttentionProfile& profile, + TimeMs now, + ObserverMemory& memory, + StepScratch& scratch, + Decision& output, + DecisionTrace* trace) noexcept +{ + if (ValidateProfile(profile) != StepStatus::Ok) { + return StepStatus::InvalidProfile; + } + ObserverFrame frame{}; + if (!TryMakeObserverFrame(observer, profile, frame)) { + return StepStatus::InvalidObserver; + } + if (memory.hasStepped && now < memory.lastStepTime) { + return StepStatus::TimeWentBackwards; + } + // Within reserved capacity, clear/push_back/resize below cannot reallocate. + if (scratch.sortedIds.capacity() < candidates.size() || + (trace != nullptr && trace->candidates.capacity() < candidates.size())) { + return StepStatus::InsufficientCapacity; + } + const ContextPreference& context = + profile.contexts[static_cast(observer.context)]; + + // Sorted IDs let every occurrence of a duplicated ID be found independently of order. + scratch.sortedIds.clear(); + for (const CandidateSnapshot& candidate : candidates) { + scratch.sortedIds.push_back(candidate.id); + } + std::sort(scratch.sortedIds.begin(), scratch.sortedIds.end()); + + if (trace != nullptr) { + trace->candidates.resize(candidates.size()); + } + + float leadingRaw = 0.f; + float currentRaw = 0.f; + + const CandidateId current = memory.current; + PolicyInput policy{}; + for (std::size_t i = 0; i < candidates.size(); ++i) { + const CandidateSnapshot& candidate = candidates[i]; + const auto range = + std::equal_range(scratch.sortedIds.begin(), scratch.sortedIds.end(), candidate.id); + const bool duplicate = range.second - range.first > 1; + + CandidateTrace evaluation{}; + EvaluateCandidate(candidate, duplicate, frame, profile, context, now, evaluation); + if (evaluation.eligibility == Eligibility::Eligible) { + if (candidate.eventStrength > 0.f) { + // Event time was validated <= now. Unsigned subtraction is the exact + // age across the entire signed time range. No cache/arrival timestamp. + const auto age = static_cast(now) - + static_cast(candidate.eventTimeMs); + evaluation.eventNovelty = static_cast(candidate.eventStrength) * + std::exp2(-static_cast(age) / + static_cast(profile.noveltyHalfLifeMs)); + } + evaluation.noveltyContribution = static_cast(profile.noveltyBeta) * + static_cast(evaluation.gate) * evaluation.eventNovelty; + // Deliberate utility saturation, not a repair of invalid input. Round + // once so tiny score differences enter the temporal policy. + evaluation.effectiveScore = static_cast((std::min)( + 1.0, static_cast(evaluation.rawScore) + evaluation.noveltyContribution)); + } + if (trace != nullptr) { + trace->candidates[i] = evaluation; + } + if (evaluation.eligibility != Eligibility::Eligible) { + continue; + } + if (Outranks(evaluation.id, evaluation.effectiveScore, policy.leading)) { + policy.leading = Ranked{evaluation.id, evaluation.effectiveScore}; + leadingRaw = evaluation.rawScore; + } + if (evaluation.id == current) { + policy.currentEligible = true; + policy.currentScore = evaluation.effectiveScore; + currentRaw = evaluation.rawScore; + } else if (Outranks(evaluation.id, evaluation.effectiveScore, policy.challenger)) { + policy.challenger = Ranked{evaluation.id, evaluation.effectiveScore}; + } + } + if (current != kNoCandidate) { + policy.dwellRemainingMs = DwellRemaining(now, memory.focusStartTime, profile.minDwellMs); + } + + const Choice choice = ApplyTemporalPolicy(current, policy, profile); + Decision decision{}; + decision.previous = current; + decision.selected = choice.selected; + decision.effectiveScore = choice.score; + decision.rawScore = choice.selected == kNoCandidate ? 0.f + : choice.selected == current ? currentRaw : leadingRaw; + decision.reason = choice.reason; + decision.kind = ClassifyDecision(decision.previous, decision.selected); + + if (trace != nullptr) { + trace->leading = policy.leading.id; + trace->leadingScore = policy.leading.score; + trace->currentEligible = policy.currentEligible; + trace->currentScore = policy.currentScore; + trace->challenger = policy.challenger.id; + trace->challengerScore = policy.challenger.score; + trace->dwellRemainingMs = policy.dwellRemainingMs; + trace->acquireThreshold = profile.acquireThreshold; + trace->releaseThreshold = profile.releaseThreshold; + trace->switchMargin = profile.switchMargin; + trace->overrideMargin = profile.overrideMargin; + } + if (decision.selected == kNoCandidate) { + memory.focusStartTime = 0; + } else if (decision.selected != current) { + memory.focusStartTime = now; + } + memory.current = decision.selected; + memory.lastStepTime = now; + memory.hasStepped = true; + output = decision; + return StepStatus::Ok; +} + +void ResetMemory(ObserverMemory& memory) noexcept +{ + memory = ObserverMemory{}; +} + +void Reserve(StepScratch& scratch, std::size_t candidateCapacity) +{ + scratch.sortedIds.reserve(candidateCapacity); +} + +void Reserve(DecisionTrace& trace, std::size_t candidateCapacity) +{ + trace.candidates.reserve(candidateCapacity); +} + +const char* ToString(StepStatus value) noexcept +{ + switch (value) { + case StepStatus::Ok: return "Ok"; + case StepStatus::TimeWentBackwards: return "TimeWentBackwards"; + case StepStatus::InvalidObserver: return "InvalidObserver"; + case StepStatus::InvalidProfile: return "InvalidProfile"; + case StepStatus::InsufficientCapacity: return "InsufficientCapacity"; + } + return "Unknown"; +} + +const char* ToString(DecisionKind value) noexcept +{ + switch (value) { + case DecisionKind::None: return "None"; + case DecisionKind::Acquire: return "Acquire"; + case DecisionKind::Keep: return "Keep"; + case DecisionKind::Switch: return "Switch"; + case DecisionKind::Clear: return "Clear"; + } + return "Unknown"; +} + +const char* ToString(DecisionReason value) noexcept +{ + switch (value) { + case DecisionReason::NoEligibleCandidate: return "NoEligibleCandidate"; + case DecisionReason::BelowAcquireThreshold: return "BelowAcquireThreshold"; + case DecisionReason::HighestScore: return "HighestScore"; + case DecisionReason::CurrentInvalid: return "CurrentInvalid"; + case DecisionReason::BelowReleaseThreshold: return "BelowReleaseThreshold"; + case DecisionReason::BetterCandidate: return "BetterCandidate"; + case DecisionReason::DwellOverride: return "DwellOverride"; + case DecisionReason::MinimumDwell: return "MinimumDwell"; + case DecisionReason::InsufficientAdvantage: return "InsufficientAdvantage"; + case DecisionReason::AboveReleaseThreshold: return "AboveReleaseThreshold"; + } + return "Unknown"; +} + +const char* ToString(Eligibility value) noexcept +{ + switch (value) { + case Eligibility::Eligible: return "Eligible"; + case Eligibility::InvalidId: return "InvalidId"; + case Eligibility::DuplicateId: return "DuplicateId"; + case Eligibility::InvalidValue: return "InvalidValue"; + case Eligibility::InvalidGeometry: return "InvalidGeometry"; + case Eligibility::Disabled: return "Disabled"; + case Eligibility::NotPerceived: return "NotPerceived"; + case Eligibility::MissingRequiredTag: return "MissingRequiredTag"; + case Eligibility::ExcludedTag: return "ExcludedTag"; + case Eligibility::OutOfRange: return "OutOfRange"; + case Eligibility::InvalidEvent: return "InvalidEvent"; + } + return "Unknown"; +} + +} // namespace interest diff --git a/src/version.cpp b/src/version.cpp new file mode 100644 index 0000000..94781ed --- /dev/null +++ b/src/version.cpp @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +#include "interest/version.hpp" + +namespace interest { + +const char* VersionString() noexcept +{ + return "0.1.0"; +} + +} // namespace interest diff --git a/tests/smoke_test.cpp b/tests/smoke_test.cpp new file mode 100644 index 0000000..855ed7a --- /dev/null +++ b/tests/smoke_test.cpp @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Verifies version metadata, C++20 and optional constrained-build compiler flags. + +#include "interest/version.hpp" + +#include +#include + +namespace { + +int failures = 0; + +void Expect(bool condition, const char* description) +{ + if (!condition) { + std::fprintf(stderr, "FAILED: %s\n", description); + ++failures; + } +} + +} // namespace + +int main() +{ + Expect(std::strcmp(interest::VersionString(), "0.1.0") == 0, + "VersionString() returns the library version"); + + Expect(__cplusplus >= 202002L, "compiled as C++20 or later"); + +#if defined(INTEREST_EXPECT_CONSTRAINED_BUILD) +#if defined(__cpp_exceptions) || defined(_CPPUNWIND) + Expect(false, "exceptions are disabled under INTEREST_CONSTRAINED_BUILD"); +#endif +#if defined(__cpp_rtti) || defined(__GXX_RTTI) || defined(_CPPRTTI) + Expect(false, "RTTI is disabled under INTEREST_CONSTRAINED_BUILD"); +#endif +#endif + + if (failures != 0) { + std::fprintf(stderr, "%d smoke check(s) failed\n", failures); + return 1; + } + std::printf("interest_smoke_test: all checks passed (version %s)\n", + interest::VersionString()); + return 0; +} diff --git a/tests/step_allocation_test.cpp b/tests/step_allocation_test.cpp new file mode 100644 index 0000000..c4e01c3 --- /dev/null +++ b/tests/step_allocation_test.cpp @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Verifies the capacity contract by counting every global allocation: once the caller +// has reserved capacity, Step must not allocate, including when it rejects an input. +// Global operator new is replaced program-wide, so this suite has its own executable. + +#include "test_support.hpp" + +#include +#include +#include + +namespace { + +std::size_t allocationCount = 0; + +} // namespace + +void* operator new(std::size_t size) +{ + ++allocationCount; + if (void* memory = std::malloc(size == 0 ? 1 : size)) { + return memory; + } + std::abort(); // keeps the replacement valid when exceptions are disabled +} + +void operator delete(void* memory) noexcept +{ + std::free(memory); +} + +void operator delete(void* memory, std::size_t) noexcept +{ + std::free(memory); +} + +namespace { + +using interest::AttentionProfile; +using interest::CandidateSnapshot; +using interest::ContextPreference; +using interest::StepStatus; + +void CounterDetectsAllocations() +{ + const std::size_t before = allocationCount; + std::vector values; + values.push_back(1); + CHECK(allocationCount > before); +} + +void PreparedStepsDoNotAllocate() +{ + constexpr std::size_t kCapacity = 32; + const ContextPreference contexts[2]{}; + AttentionProfile profile = test::Profile(contexts); + // A release band and dwell, so the steps also take the temporal-policy paths. + profile.releaseThreshold = 0.2f; + profile.switchMargin = 0.05f; + profile.overrideMargin = 0.2f; + profile.minDwellMs = 250; + profile.noveltyBeta = 0.75f; + + // Build every input up front so that only Step runs inside the counted region. + test::Random rng(0x5EED0003u); + std::vector> frames(400); + for (std::vector& frame : frames) { + frame.resize(static_cast(rng.Below(kCapacity + 1))); + for (CandidateSnapshot& c : frame) { + c = test::PointAt(rng.Below(24), 0, 0, 0.f, 0.f); // ids 0..23: duplicates common + c.position.x = static_cast(rng.Below(41)) - 20.0; + c.position.y = static_cast(rng.Below(41)) - 20.0; + c.attraction = rng.Unit(); + c.salience = rng.Unit(); + c.spatial = static_cast(rng.Below(3)); + c.bearing = {1.f, 0.f, 0.f}; + c.enabled = rng.Below(8) != 0; + c.eventTimeMs = -100; + c.eventStrength = 0.75f; + } + } + // Repeated population changes with eligible inputs and fresh events, independent + // of the randomized rejection coverage above. No per-candidate memory remains. + for (std::size_t i = 200; i < frames.size(); ++i) { + frames[i].clear(); + for (std::size_t j = 0; j < kCapacity; ++j) { + frames[i].push_back(test::NonSpatial(1000 + (i % 2) * 100 + j, 0.8f, 0.8f)); + frames[i].back().eventTimeMs = static_cast((i + 1) * 100 - 50); + frames[i].back().eventStrength = 0.8f; + } + } + const std::vector oversized(kCapacity + 1, test::NonSpatial(1, 1.f, 1.f)); + + interest::ObserverMemory memory{}; + interest::StepScratch scratch; + interest::DecisionTrace trace; + interest::Decision output{}; + interest::Reserve(scratch, kCapacity); + interest::Reserve(trace, kCapacity); + AttentionProfile invalidProfile = profile; + invalidProfile.noveltyHalfLifeMs = 0; + auto invalidObserver = test::Observer(); + invalidObserver.referenceForward = {}; + + const std::size_t before = allocationCount; + bool allOk = true; + interest::TimeMs now = 0; + for (const std::vector& frame : frames) { + now += 100; + const bool traced = now % 300 != 0; + const auto context = static_cast(now % 2); + if (interest::Step(test::Observer(context), frame, profile, now, memory, scratch, + output, traced ? &trace : nullptr) != StepStatus::Ok) { + allOk = false; + } + } + // Rejections do not allocate either. + const bool rejected = + interest::Step(test::Observer(), oversized, profile, now, memory, scratch, output, + &trace) == StepStatus::InsufficientCapacity && + interest::Step(test::Observer(), frames[0], profile, now - 1, memory, scratch, output, + &trace) == StepStatus::TimeWentBackwards && + interest::Step(test::Observer(), frames[0], invalidProfile, now, memory, scratch, + output, &trace) == StepStatus::InvalidProfile && + interest::Step(invalidObserver, frames[0], profile, now, memory, scratch, + output, &trace) == StepStatus::InvalidObserver; + const std::size_t allocations = allocationCount - before; + + CHECK(allOk && rejected); + CHECK(allocations == 0); + std::printf(" steps: %zu, allocations during steps: %zu\n", frames.size() + 4, + allocations); +} + +} // namespace + +int main() +{ + test::Run("allocation counter detects allocations", CounterDetectsAllocations); + test::Run("prepared steps do not allocate", PreparedStepsDoNotAllocate); + return test::Finish("interest_allocation_test"); +} diff --git a/tests/step_baseline_test.cpp b/tests/step_baseline_test.cpp new file mode 100644 index 0000000..cb40b98 --- /dev/null +++ b/tests/step_baseline_test.cpp @@ -0,0 +1,409 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Compares B1 (stateless acquisition of the leading score) with B2 (the kernel's +// contextual scoring and temporal policy) on identical candidate streams. Novelty +// is disabled. Each step runs Step() once; B1 is read from that call's trace so both +// baselines see exactly the same scores. +// +// Ablations rerun each stream with one temporal mechanism changed in the profile: +// no band releaseThreshold = acquireThreshold +// no dwell minDwellMs = 0 +// override~off overrideMargin = 1. This only approximates disabling override: +// a score of 1 can still override a target at 0 during dwell. Each +// run asserts no DwellOverride occurred, making it exact here. +// min margin switchMargin = 0.001 (a margin cannot be zero) +// Every step is checked against the policy rules through test::ConsistentStep. +// These synthetic streams measure mechanism effects, not natural attention quality. + +#include "test_support.hpp" + +#include +#include + +namespace { + +using interest::AttentionProfile; +using interest::CandidateId; +using interest::CandidateSnapshot; +using interest::ContextPreference; +using interest::DecisionKind; +using interest::DecisionReason; +using interest::StepStatus; +using interest::TimeMs; +using test::Harness; +using test::Observer; +using test::Scored; + +constexpr TimeMs kInterval = 100; // all streams run at 10 Hz +constexpr CandidateId kEvent = 50; // a clearly superior challenger, where a stream has one +constexpr int kReasons = 10; + +TimeMs At(int step) +{ + return static_cast(step) * kInterval; +} + +struct Frame { + TimeMs now = 0; + std::vector candidates; +}; +using Stream = std::vector; + +// Target changes of one baseline over a stream, and its response to kEvent. +class Tally { +public: + int acquires = 0; + int switches = 0; + int clears = 0; + int events = 0; // appearances of kEvent + int captured = 0; // appearances followed by selecting kEvent while it is present + TimeMs latencySum = 0; + TimeMs latencyMax = 0; + + void Record(TimeMs now, CandidateId next, bool eventPresent) + { + if (selected_ == 0 && next != 0) { + ++acquires; + } else if (selected_ != 0 && next == 0) { + ++clears; + } else if (selected_ != next) { + ++switches; + } + if (eventPresent && !eventWasPresent_) { + ++events; + pending_ = true; + onset_ = now; + } + if (!eventPresent) { + pending_ = false; // missed + } + if (pending_ && next == kEvent) { + ++captured; + latencySum += now - onset_; + latencyMax = now - onset_ > latencyMax ? now - onset_ : latencyMax; + pending_ = false; + } + eventWasPresent_ = eventPresent; + selected_ = next; + } + + bool SameAs(const Tally& other) const + { + return acquires == other.acquires && switches == other.switches && + clears == other.clears && events == other.events && + captured == other.captured && latencySum == other.latencySum && + latencyMax == other.latencyMax; + } + +private: + CandidateId selected_ = 0; + bool eventWasPresent_ = false; + bool pending_ = false; + TimeMs onset_ = 0; +}; + +struct Result { + Tally b2; + Tally b1; + int differing = 0; // steps where B2 and B1 select differently + int reasonCounts[kReasons]{}; + bool consistent = true; +}; + +Result RunStream(const Stream& stream, const AttentionProfile& profile) +{ + Result result; + Harness h; + for (const Frame& frame : stream) { + if (h.Run(Observer(), frame.candidates, profile, frame.now) != StepStatus::Ok || + !test::ConsistentStep(h, profile)) { + result.consistent = false; + } + bool eventPresent = false; + for (const CandidateSnapshot& candidate : frame.candidates) { + eventPresent = eventPresent || candidate.id == kEvent; + } + const CandidateId stateless = test::StatelessSelection(h.trace); + result.b2.Record(frame.now, h.output.selected, eventPresent); + result.b1.Record(frame.now, stateless, eventPresent); + result.differing += h.output.selected != stateless ? 1 : 0; + const auto reason = static_cast(h.output.reason); + if (reason < kReasons) { + ++result.reasonCounts[reason]; + } + } + return result; +} + +enum Variant { kB2, kNoBand, kNoDwell, kNoOverride, kMinMargin, kVariantCount }; +const char* const kVariantNames[kVariantCount]{"B2", "B2 no band", "B2 no dwell", + "B2 override~off", "B2 min margin"}; + +AttentionProfile Ablate(AttentionProfile profile, int variant) +{ + switch (variant) { + case kNoBand: profile.releaseThreshold = profile.acquireThreshold; break; + case kNoDwell: profile.minDwellMs = 0; break; + case kNoOverride: profile.overrideMargin = 1.f; break; + case kMinMargin: profile.switchMargin = 0.001f; break; + default: break; + } + return profile; +} + +struct Comparison { + Tally b1; + Result runs[kVariantCount]; +}; + +void PrintRow(const char* name, const Tally& tally, double minutes, int differing) +{ + const int changes = tally.acquires + tally.switches + tally.clears; + std::printf(" %-15s %5d %5d %5d %8.1f %6d %6d %8.1f %6lld %8d\n", name, tally.acquires, + tally.switches, tally.clears, changes / minutes, tally.events, tally.captured, + tally.captured > 0 ? static_cast(tally.latencySum) / tally.captured : 0.0, + static_cast(tally.latencyMax), differing); +} + +// Runs B2 and its ablations on one stream, checks every step, and prints the table. +Comparison Compare(const char* title, const Stream& stream, const AttentionProfile& profile) +{ + Comparison comparison; + bool consistent = true; + bool sameB1 = true; + for (int variant = 0; variant < kVariantCount; ++variant) { + comparison.runs[variant] = RunStream(stream, Ablate(profile, variant)); + consistent = consistent && comparison.runs[variant].consistent; + if (variant == kNoOverride) { + CHECK(comparison.runs[variant] + .reasonCounts[static_cast(DecisionReason::DwellOverride)] == 0); + } + // B1 depends only on scores and acquireThreshold, which no ablation changes. + sameB1 = sameB1 && comparison.runs[variant].b1.SameAs(comparison.runs[kB2].b1); + } + comparison.b1 = comparison.runs[kB2].b1; + CHECK(consistent); + CHECK(sameB1); + + const double minutes = + static_cast(stream.back().now - stream.front().now + kInterval) / 60000.0; + std::printf(" %s (%zu steps)\n", title, stream.size()); + std::printf(" %-15s %5s %5s %5s %8s %6s %6s %8s %6s %8s\n", "baseline", "acq", "sw", "clr", + "chg/min", "events", "taken", "mean ms", "max ms", "!= B1"); + PrintRow("B1 stateless", comparison.b1, minutes, 0); + for (int variant = 0; variant < kVariantCount; ++variant) { + PrintRow(kVariantNames[variant], comparison.runs[variant].b2, minutes, + comparison.runs[variant].differing); + } + return comparison; +} + +const Tally& B2(const Comparison& c, int variant) +{ + return c.runs[variant].b2; +} + +// C08: two sources trade the lead between 0.71 and 0.69 on every step, for 60 s. +void AlternatingPair() +{ + const ContextPreference contexts[1]{}; + Stream stream; + for (int step = 0; step < 600; ++step) { + const bool firstLeads = step % 2 == 0; + stream.push_back({At(step), {Scored(1, firstLeads ? 0.71f : 0.69f), + Scored(2, firstLeads ? 0.69f : 0.71f)}}); + } + const Comparison c = Compare("C08 pair alternating 0.71/0.69", stream, + test::TemporalProfile(contexts)); + CHECK(c.b1.switches == 599); + for (int variant : {kB2, kNoBand, kNoDwell, kNoOverride}) { + CHECK(B2(c, variant).acquires == 1 && B2(c, variant).switches == 0); + } + // Dwell alone only paces the flicker: one switch each time a 500 ms dwell ends. + CHECK(B2(c, kMinMargin).switches == 119); +} + +// C08 with random noise: three sources at 0.70 +- 0.04, for 60 s. No lead reaches the +// 0.125 margin. +void NoisyTrio() +{ + const ContextPreference contexts[1]{}; + test::Random rng(0x5EED0B01u); + Stream stream; + for (int step = 0; step < 600; ++step) { + Frame frame{At(step), {}}; + for (CandidateId id = 1; id <= 3; ++id) { + frame.candidates.push_back(Scored(id, 0.66f + 0.08f * rng.Unit())); + } + stream.push_back(frame); + } + const Comparison c = + Compare("C08 three sources at 0.70 +- 0.04", stream, test::TemporalProfile(contexts)); + CHECK(c.b1.switches > 300); + for (int variant : {kB2, kNoBand, kNoDwell, kNoOverride}) { + CHECK(B2(c, variant).acquires == 1 && B2(c, variant).switches == 0); + } + // Without the margin, dwell allows at most one switch per 500 ms: 120 in 60 s. + CHECK(B2(c, kMinMargin).switches > 0 && B2(c, kMinMargin).switches <= 120); +} + +// C09: one source at 0.5 +- 0.1, around acquire 0.5 and above release 0.375, for 60 s. +void SourceAtThreshold() +{ + const ContextPreference contexts[1]{}; + test::Random rng(0x5EED0B02u); + Stream stream; + for (int step = 0; step < 600; ++step) { + stream.push_back({At(step), {Scored(1, 0.4f + 0.2f * rng.Unit())}}); + } + const Comparison c = + Compare("C09 one source at 0.5 +- 0.1", stream, test::TemporalProfile(contexts)); + CHECK(c.b1.clears > 100); + for (int variant : {kB2, kNoDwell, kNoOverride, kMinMargin}) { + CHECK(B2(c, variant).acquires == 1 && B2(c, variant).clears == 0); + } + // Without the band, dwell paces the flicker but does not stop it: each clear needs a + // 500 ms dwell and a step to reacquire, so at most 100 in 60 s. + CHECK(B2(c, kNoBand).clears > 10 && B2(c, kNoBand).clears <= 100); +} + +// C11/C11a: in each 3 s cycle a target (0.625) is acquired, then a challenger appears +// 100-600 ms later, while dwell may still run. +Stream ChallengerCycles(float challengerScore) +{ + Stream stream; + int step = 0; + for (int delay = 1; delay <= 6; ++delay) { + stream.push_back({At(step++), {}}); // clears the previous cycle + stream.push_back({At(step++), {Scored(1, 0.625f)}}); + for (int i = 1; i < 29; ++i) { + Frame frame{At(step++), {Scored(1, 0.625f)}}; + if (i >= delay) { + frame.candidates.push_back(Scored(kEvent, challengerScore)); + } + stream.push_back(frame); + } + } + return stream; +} + +void PersistentChallenger() +{ + const ContextPreference contexts[1]{}; + const Comparison c = Compare("C11 challenger leading by switchMargin (0.75 vs 0.625)", + ChallengerCycles(0.75f), test::TemporalProfile(contexts)); + CHECK(c.b1.captured == 6 && c.b1.latencyMax == 0); + // Dwell delays the switch to its end: latencies 400, 300, 200, 100, 0 and 0 ms. + for (int variant : {kB2, kNoBand, kNoOverride, kMinMargin}) { + CHECK(B2(c, variant).captured == 6); + CHECK(B2(c, variant).latencyMax == 400 && B2(c, variant).latencySum == 1000); + } + CHECK(B2(c, kNoDwell).captured == 6 && B2(c, kNoDwell).latencyMax == 0); +} + +void StrongChallenger() +{ + const ContextPreference contexts[1]{}; + const Comparison c = Compare("C11a challenger leading by overrideMargin (0.875 vs 0.625)", + ChallengerCycles(0.875f), test::TemporalProfile(contexts)); + for (int variant : {kB2, kNoBand, kNoDwell, kMinMargin}) { + CHECK(B2(c, variant).captured == 6 && B2(c, variant).latencyMax == 0); + } + // Without the override it waits for dwell like the weaker challenger. + CHECK(B2(c, kNoOverride).captured == 6); + CHECK(B2(c, kNoOverride).latencyMax == 400 && B2(c, kNoOverride).latencySum == 1000); +} + +// Four sources whose interest drifts slowly in [0.2, 0.8], with +-0.05 noise per step and +// each missing from 2% of steps; once in every 30 s block, a sudden event (0.95) for 1 s. +// Ten minutes at 10 Hz. +Stream MixedStream() +{ + test::Random rng(0x5EED0B03u); + float base[4]{0.45f, 0.55f, 0.62f, 0.7f}; + int eventStart = 0; + Stream stream; + for (int step = 0; step < 6000; ++step) { + Frame frame{At(step), {}}; + for (std::size_t i = 0; i < 4; ++i) { + float drifted = base[i] + 0.02f * (rng.Unit() - 0.5f); + drifted = drifted < 0.2f ? 0.4f - drifted : (drifted > 0.8f ? 1.6f - drifted : drifted); + base[i] = drifted; + const float noise = 0.1f * (rng.Unit() - 0.5f); + if (rng.Below(50) != 0) { + frame.candidates.push_back(Scored(static_cast(i + 1), drifted + noise)); + } + } + if (step % 300 == 0) { + eventStart = step + static_cast(rng.Below(290)); + } + if (step >= eventStart && step < eventStart + 10) { + frame.candidates.push_back(Scored(kEvent, 0.95f)); + } + stream.push_back(frame); + } + return stream; +} + +void MixedStreamComparison() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = test::TemporalProfile(contexts); + const Comparison c = Compare("generated: drifting sources, dropouts and sudden events", + MixedStream(), profile); + std::printf(" B2 reasons:"); + for (int reason = 0; reason < kReasons; ++reason) { + std::printf(" %s %d", interest::ToString(static_cast(reason)), + c.runs[kB2].reasonCounts[reason]); + } + std::printf("\n"); + + // Recorded outcomes for this stream; the exact counts are printed above. + const Tally& stable = B2(c, kB2); + const int statelessChanges = c.b1.acquires + c.b1.switches + c.b1.clears; + const int stableChanges = stable.acquires + stable.switches + stable.clears; + CHECK(stableChanges * 3 < statelessChanges); + CHECK(stable.clears * 3 < c.b1.clears); + CHECK(c.b1.captured == c.b1.events && c.b1.latencyMax == 0); + // The event (0.95) leads any target below 0.825 by the switch margin, so it is taken + // at once or when dwell ends. A target above 0.825 would hold it off; none did here. + CHECK(stable.events == 20 && stable.captured == 20); + CHECK(stable.latencyMax <= profile.minDwellMs); +} + +// The policy rules hold on every step for random valid profiles over the generated stream. +void RulesHoldForRandomProfiles() +{ + const ContextPreference contexts[1]{}; + const Stream stream = MixedStream(); + test::Random rng(0x5EED0B04u); + bool valid = true; + bool consistent = true; + for (int i = 0; i < 25; ++i) { + AttentionProfile profile = test::TemporalProfile(contexts); + profile.acquireThreshold = 0.3f + 0.6f * rng.Unit(); + profile.releaseThreshold = profile.acquireThreshold * (0.5f + 0.5f * rng.Unit()); + profile.switchMargin = 0.001f + 0.3f * rng.Unit(); + profile.overrideMargin = profile.switchMargin + (1.f - profile.switchMargin) * rng.Unit(); + profile.minDwellMs = static_cast(rng.Below(2000)); + valid = valid && interest::ValidateProfile(profile) == StepStatus::Ok; + consistent = consistent && RunStream(stream, profile).consistent; + } + CHECK(valid); + CHECK(consistent); +} + +} // namespace + +int main() +{ + test::Run("C08 alternating pair", AlternatingPair); + test::Run("C08 noisy trio", NoisyTrio); + test::Run("C09 source at the acquire threshold", SourceAtThreshold); + test::Run("C11 persistent challenger", PersistentChallenger); + test::Run("C11a strong challenger", StrongChallenger); + test::Run("generated mixed stream", MixedStreamComparison); + test::Run("policy rules hold for random profiles", RulesHoldForRandomProfiles); + return test::Finish("interest_baseline_test"); +} diff --git a/tests/step_c07c_test.cpp b/tests/step_c07c_test.cpp new file mode 100644 index 0000000..c41c222 --- /dev/null +++ b/tests/step_c07c_test.cpp @@ -0,0 +1,331 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Compares weighted-mean, max and soft-OR preference formulas through the real kernel. +// Alternative formulas are local test controls, not production options. + +#include "test_support.hpp" + +#include +#include + +namespace { + +using interest::AttentionProfile; +using interest::CandidateId; +using interest::CandidateSnapshot; +using interest::CandidateTrace; +using interest::ContextPreference; +using interest::Eligibility; +using interest::StepStatus; +using interest::TagMask; +using test::Harness; +using test::Near; +using test::Observer; +using test::PointAt; + +enum class Model { Mean, Max, SoftOR }; +constexpr Model kModels[]{Model::Mean, Model::Max, Model::SoftOR}; + +const char* Name(Model model) +{ + switch (model) { + case Model::Mean: return "Mean"; + case Model::Max: return "Max"; + case Model::SoftOR: return "SoftOR"; + } + return "?"; +} + +double Preference(Model model, const CandidateTrace& e, const AttentionProfile& profile) +{ + const double a = static_cast(e.attraction); + const double s = static_cast(e.salience); + const double f = static_cast(e.affinity); + switch (model) { + case Model::Mean: { + const double wa = static_cast(profile.attractionWeight); + const double ws = static_cast(profile.salienceWeight); + const double wf = static_cast(profile.affinityWeight); + return (wa * a + ws * s + wf * f) / (wa + ws + wf); + } + case Model::Max: return a > s ? (a > f ? a : f) : (s > f ? s : f); + case Model::SoftOR: return 1.0 - (1.0 - a) * (1.0 - s) * (1.0 - f); + } + return 0.0; +} + +struct Outcome { + CandidateId selected = 0; + CandidateId leading = 0; + float leadingScore = 0.f; + float runnerUpScore = 0.f; // second-highest eligible score +}; + +float Score(Model model, const CandidateTrace& e, const AttentionProfile& profile) +{ + return static_cast(static_cast(e.gate) * Preference(model, e, profile)); +} + +Outcome Select(Model model, const Harness& h, const AttentionProfile& profile) +{ + Outcome out; + bool any = false; + for (const CandidateTrace& e : h.trace.candidates) { + if (e.eligibility != Eligibility::Eligible) { + continue; + } + const float score = Score(model, e, profile); + if (!any || score > out.leadingScore || (score == out.leadingScore && e.id < out.leading)) { + if (any) { + out.runnerUpScore = out.leadingScore; + } + out.leading = e.id; + out.leadingScore = score; + any = true; + } else if (score > out.runnerUpScore) { + out.runnerUpScore = score; + } + } + out.selected = any && out.leadingScore >= profile.acquireThreshold ? out.leading : 0; + return out; +} + +// Affinity is carried by tags so it is explicit per candidate; untagged means 0. +constexpr unsigned kAffinityBits = 11; +constexpr float kAffinityByBit[kAffinityBits]{0.f, 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, + 0.6f, 0.7f, 0.8f, 0.9f, 1.f}; + +ContextPreference ExplicitAffinityContext() +{ + ContextPreference context{}; + context.fallbackAffinity = 0.f; + for (unsigned bit = 0; bit < kAffinityBits; ++bit) { + context.affinityTags |= TagMask{1} << bit; + context.tagAffinity[bit] = kAffinityByBit[bit]; + } + return context; +} + +// affinityTenths: 0..10 selects affinity 0.0..1.0. +CandidateSnapshot Stimulus(CandidateId id, double x, double y, float attraction, float salience, + unsigned affinityTenths) +{ + CandidateSnapshot c = PointAt(id, x, y, attraction, salience); + c.tags = TagMask{1} << affinityTenths; + return c; +} + +struct Case { + const char* name; + std::vector candidates; + CandidateId expected[3]; // Mean, Max, SoftOR +}; + +void FixedScenarios() +{ + const ContextPreference contexts[1]{ExplicitAffinityContext()}; + const AttentionProfile profile = test::Profile(contexts); // equal weights, threshold 0.3 + + // Equal gates: every Point sits at distance 5 straight ahead unless stated. + const std::vector cases{ + {"C07c-1 sudden pure-salience event vs steady interest", + {Stimulus(1, 5, 0, 0.f, 1.f, 0), Stimulus(2, 5, 0, 0.6f, 0.6f, 6)}, + {2, 1, 1}}, + {"C07c-2 weak distractor vs steady interest", + {Stimulus(1, 5, 0, 0.1f, 0.7f, 1), Stimulus(2, 5, 0, 0.6f, 0.6f, 6)}, + {2, 1, 2}}, + {"C07c-3 one saturated cue vs uniformly strong", + {Stimulus(1, 5, 0, 0.f, 1.f, 0), Stimulus(2, 5, 0, 1.f, 1.f, 10)}, + {2, 1, 1}}, // Max and SoftOR tie at 1.0; the lower ID wins, not the better source + {"C07c-4 lone weak cue (no target expected under mean)", + {Stimulus(1, 5, 0, 0.f, 0.35f, 0)}, + {0, 1, 1}}, + {"C07c-5 several moderate cues vs one strong cue", + {Stimulus(1, 5, 0, 0.5f, 0.5f, 5), Stimulus(2, 5, 0, 0.8f, 0.f, 1)}, + {1, 2, 1}}, + {"C07c-6 sudden event behind the observer (gate 0.2) vs steady interest ahead", + {Stimulus(1, -5, 0, 0.f, 1.f, 0), Stimulus(2, 5, 0, 0.6f, 0.6f, 6)}, + {2, 2, 2}}, + }; + + std::printf(" %-74s %8s %8s %8s\n", "case (selected id, leading score)", "Mean", "Max", + "SoftOR"); + for (const Case& c : cases) { + Harness h; + CHECK(h.Run(Observer(), c.candidates, profile, 0) == StepStatus::Ok); + std::printf(" %-74s", c.name); + for (std::size_t m = 0; m < 3; ++m) { + const Outcome outcome = Select(kModels[m], h, profile); + std::printf(" %2llu %5.3f", static_cast(outcome.selected), + static_cast(outcome.leadingScore)); + if (outcome.selected != c.expected[m]) { + std::fprintf(stderr, " %s: %s selected %llu, expected %llu\n", c.name, + Name(kModels[m]), static_cast(outcome.selected), + static_cast(c.expected[m])); + CHECK(outcome.selected == c.expected[m]); + } + if (kModels[m] == Model::Mean) { // the experiment's Mean is the kernel decision + CHECK(outcome.selected == h.output.selected); + CHECK(outcome.leading == h.trace.leading); + CHECK(Near(outcome.leadingScore, static_cast(h.trace.leadingScore), + 1e-6)); + } + } + std::printf("\n"); + + if (&c == &cases[2]) { // saturation: Max and SoftOR cannot tell the two apart + for (Model model : {Model::Max, Model::SoftOR}) { + CHECK(Score(model, *h.Find(1), profile) == Score(model, *h.Find(2), profile)); + } + } + } + + // Discrimination between two strong sources (0.9 vs 0.8 on every cue). A temporal switch + // margin is an absolute score difference, so a compressed gap matters. + const CandidateSnapshot strong[]{Stimulus(1, 5, 0, 0.9f, 0.9f, 9), + Stimulus(2, 5, 0, 0.8f, 0.8f, 8)}; + Harness gap; + CHECK(gap.Run(Observer(), strong, profile, 0) == StepStatus::Ok); + std::printf(" C07c-7 score gap, all cues 0.9 vs 0.8:"); + float gaps[3]{}; + for (std::size_t m = 0; m < 3; ++m) { + const Outcome outcome = Select(kModels[m], gap, profile); + gaps[m] = outcome.leadingScore - outcome.runnerUpScore; + std::printf(" %s %.4f", Name(kModels[m]), static_cast(gaps[m])); + CHECK(outcome.selected == 1); + } + std::printf("\n"); + CHECK(Near(gaps[0], 0.1, 1e-5) && Near(gaps[1], 0.1, 1e-5) && Near(gaps[2], 0.007, 1e-5)); + + // Salience-weighted mean: the kernel's own weights, no new mechanism. + AttentionProfile salient = profile; + salient.salienceWeight = 4.f; + std::printf(" salience-weighted Mean (1,4,1):"); + for (std::size_t i = 0; i < 2; ++i) { + Harness h; + CHECK(h.Run(Observer(), cases[i].candidates, salient, 0) == StepStatus::Ok); + std::printf(" %s -> %llu", i == 0 ? "C07c-1" : "C07c-2", + static_cast(h.output.selected)); + CHECK(h.output.selected == (i == 0 ? 1u : 2u)); // takes the event, ignores the distractor + } + std::printf("\n"); +} + +// A generated stream. A steady source of moderate interest stays at a fixed, well-gated +// position ahead (6, 1). Each step adds 0-3 weak distractors (low attraction/affinity, +// salience 0.3-0.8) ahead of the observer, and in about 15% of steps a sudden event +// (salience 0.95-1, low attraction, no affinity) near and ahead. Selecting a distractor +// is a false distraction because the steady source is always available. Gates differ +// between candidates but are identical across models. +void GeneratedStream() +{ + const ContextPreference contexts[1]{ExplicitAffinityContext()}; + const AttentionProfile equal = test::Profile(contexts); + // Salience-weight sweep for the kernel's own Mean model (sensitivity, not tuning). + AttentionProfile salient[4]{equal, equal, equal, equal}; + salient[0].salienceWeight = 2.f; + salient[1].salienceWeight = 3.f; + salient[2].salienceWeight = 4.f; + salient[3].salienceWeight = 6.f; + + struct Row { + const char* label; + Model model; + const AttentionProfile* profile; + int distracted = 0; + int captured = 0; + int none = 0; + }; + Row rows[]{{"Mean (1,1,1)", Model::Mean, &equal, 0, 0, 0}, + {"Mean (1,2,1)", Model::Mean, &salient[0], 0, 0, 0}, + {"Mean (1,3,1)", Model::Mean, &salient[1], 0, 0, 0}, + {"Mean (1,4,1)", Model::Mean, &salient[2], 0, 0, 0}, + {"Mean (1,6,1)", Model::Mean, &salient[3], 0, 0, 0}, + {"Max", Model::Max, &equal, 0, 0, 0}, + {"SoftOR", Model::SoftOR, &equal, 0, 0, 0}}; + + test::Random rng(0x5EED0C07u); + const auto between = [&rng](float lo, float hi) { return lo + (hi - lo) * rng.Unit(); }; + // x in [x0, x0 + xSpan], y in [-ySpan/2, ySpan/2], whole units. + const auto position = [&rng](double x0, std::uint64_t xSpan, std::uint64_t ySpan, double& x, + double& y) { + x = x0 + static_cast(rng.Below(xSpan + 1)); + y = static_cast(rng.Below(ySpan + 1)) - static_cast(ySpan) / 2.0; + }; + + constexpr int kSteps = 4000; + int eventSteps = 0; + for (int stepIndex = 0; stepIndex < kSteps; ++stepIndex) { + std::vector candidates; + double x = 0.0; + double y = 0.0; + candidates.push_back(Stimulus(1, 6, 1, 0.6f, 0.6f, 6)); // steady interest + const auto distractors = rng.Below(4); + for (CandidateId d = 0; d < distractors; ++d) { + position(2.0, 12, 16, x, y); + const float attraction = between(0.f, 0.2f); + const float salience = between(0.3f, 0.8f); + candidates.push_back( + Stimulus(10 + d, x, y, attraction, salience, static_cast(rng.Below(2)))); + } + const bool event = rng.Below(100) < 15; + if (event) { + ++eventSteps; + position(2.0, 7, 10, x, y); + const float attraction = between(0.f, 0.2f); + const float salience = between(0.95f, 1.f); + candidates.push_back(Stimulus(50, x, y, attraction, salience, 0)); + } + + for (Row& row : rows) { + Harness h; + if (h.Run(Observer(), candidates, *row.profile, 0) != StepStatus::Ok) { + CHECK(false); + continue; + } + const CandidateId selected = Select(row.model, h, *row.profile).selected; + if (row.model == Model::Mean) { + CHECK(selected == h.output.selected); // matches the kernel + } + row.distracted += selected >= 10 && selected < 50 ? 1 : 0; + row.captured += selected == 50 ? 1 : 0; + row.none += selected == 0 ? 1 : 0; + } + } + + std::printf(" stream: %d steps, %d with an event\n", kSteps, eventSteps); + std::printf(" %-14s %22s %22s %10s\n", "model", "distractor selected", "event selected", + "no target"); + for (const Row& row : rows) { + std::printf(" %-14s %10d (%5.1f%%) %10d (%5.1f%%) %10d\n", row.label, row.distracted, + 100.0 * row.distracted / kSteps, row.captured, + eventSteps > 0 ? 100.0 * row.captured / eventSteps : 0.0, row.none); + } + + // Recorded qualitative outcomes (exact counts are printed above). + const Row& meanRow = rows[0]; + const Row& salientRow = rows[3]; + const Row& maxRow = rows[5]; + const Row& softOrRow = rows[6]; + for (const Row& row : rows) { + CHECK(row.none == 0); // the steady source always qualifies + } + // Equal-weight mean: never distracted, but never takes a pure-salience event. + CHECK(meanRow.distracted == 0 && meanRow.captured == 0); + // Max takes every event and many weak distractors. + CHECK(maxRow.captured == eventSteps && maxRow.distracted > kSteps / 10); + // SoftOR is never distracted here and takes about half of the events. + CHECK(softOrRow.distracted == 0 && softOrRow.captured > eventSteps / 3); + // Salience-weighted mean (1,4,1): no distraction, more events than SoftOR. + CHECK(salientRow.distracted == 0 && salientRow.captured > softOrRow.captured); +} + +} // namespace + +int main() +{ + test::Run("C07c fixed scenarios", FixedScenarios); + test::Run("C07c generated stream", GeneratedStream); + return test::Finish("interest_c07c_test"); +} diff --git a/tests/step_c18_test.cpp b/tests/step_c18_test.cpp new file mode 100644 index 0000000..5de5bfe --- /dev/null +++ b/tests/step_c18_test.cpp @@ -0,0 +1,353 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Sustained candidate churn, invalid inputs and exact replay of decisions, memory and traces. +#include "test_support.hpp" + +#include +#include +#include +#include + +namespace { +using interest::AttentionProfile; +using interest::CandidateId; +using interest::CandidateSnapshot; +using interest::CandidateTrace; +using interest::ContextId; +using interest::ContextPreference; +using interest::DecisionReason; +using interest::DecisionTrace; +using interest::Eligibility; +using interest::ObserverMemory; +using interest::ObserverSnapshot; +using interest::Reserve; +using interest::ResetMemory; +using interest::SpatialKind; +using interest::Step; +using interest::StepStatus; +using interest::TagMask; +using interest::TimeMs; + +constexpr std::size_t kCapacity = 72; +constexpr int kSteps = 12000; + +bool SameFloat(float a, float b) +{ + return std::bit_cast(a) == std::bit_cast(b); +} + +bool SameEntry(const CandidateTrace& a, const CandidateTrace& b) +{ + // Compare fields, not padding; copied invalid inputs may contain NaNs. + bool same = a.id == b.id && a.eligibility == b.eligibility && + SameFloat(a.attraction, b.attraction) && SameFloat(a.salience, b.salience) && + SameFloat(a.perceptionStrength, b.perceptionStrength) && a.distance == b.distance && + a.distanceGate == b.distanceGate && a.angleGate == b.angleGate && a.gate == b.gate && + a.affinity == b.affinity && a.preference == b.preference && a.rawScore == b.rawScore; + same = same && a.effectiveScore == b.effectiveScore && a.eventTimeMs == b.eventTimeMs && + SameFloat(a.eventStrength, b.eventStrength) && a.eventNovelty == b.eventNovelty && + a.noveltyContribution == b.noveltyContribution; + return same; +} + +bool SameTrace(const DecisionTrace& a, const DecisionTrace& b) +{ + if (!test::SamePolicyTrace(a, b) || a.candidates.size() != b.candidates.size()) { + return false; + } + for (std::size_t i = 0; i < a.candidates.size(); ++i) { + if (!SameEntry(a.candidates[i], b.candidates[i])) { return false; } + } + return true; +} + +// A diagnostic fingerprint of every base decision, memory and policy-trace +// field, independent of object layout/endian/padding. Full exact transition files +// can additionally be emitted for byte comparison with another build of this test. +void Record(std::FILE* file, std::uint64_t& hash, std::uint64_t value) +{ + for (unsigned byte = 0; byte < 8; ++byte) { + const auto part = static_cast((value >> (byte * 8)) & 255u); + hash = (hash ^ part) * 1099511628211ull; + if (file != nullptr) { CHECK(std::fputc(part, file) != EOF); } + } +} + +void RecordStep(std::FILE* file, std::uint64_t& hash, const test::Harness& h) +{ + const auto& d = h.output; + const auto& m = h.memory; + const auto& t = h.trace; + const std::uint64_t fields[]{ + static_cast(d.kind), static_cast(d.reason), + d.selected, d.previous, std::bit_cast(d.rawScore), + m.current, static_cast(m.focusStartTime), + static_cast(m.lastStepTime), m.hasStepped ? 1u : 0u, + t.leading, std::bit_cast(t.leadingScore), t.currentEligible ? 1u : 0u, + std::bit_cast(t.currentScore), t.challenger, + std::bit_cast(t.challengerScore), + static_cast(t.dwellRemainingMs), + std::bit_cast(t.acquireThreshold), + std::bit_cast(t.releaseThreshold), + std::bit_cast(t.switchMargin), + std::bit_cast(t.overrideMargin)}; + for (auto value : fields) { Record(file, hash, value); } +} + +void InjectErrors(test::Harness& h, const ObserverSnapshot& observer, + const AttentionProfile& p, TimeMs now) +{ + const test::Harness before = h; + const auto* scratchData = h.scratch.sortedIds.data(); + const auto* traceData = h.trace.candidates.data(); + const auto scratchCapacity = h.scratch.sortedIds.capacity(); + const auto traceCapacity = h.trace.candidates.capacity(); + const std::array frame{test::Scored(10000, 1.f)}; + const std::vector oversized(scratchCapacity + 1, frame[0]); + auto invalidProfile = p; invalidProfile.attractionWeight = -1.f; + auto invalidObserver = observer; invalidObserver.referenceForward = {}; + const auto check = [&](StepStatus expected, const ObserverSnapshot& o, + const AttentionProfile& profile, TimeMs time, + std::span candidates) { + CHECK(Step(o, candidates, profile, time, h.memory, h.scratch, h.output, + &h.trace) == expected); + CHECK(test::SameMemory(before.memory, h.memory)); + CHECK(test::SameDecision(before.output, h.output)); + CHECK(SameTrace(before.trace, h.trace)); + CHECK(before.scratch.sortedIds == h.scratch.sortedIds); + CHECK(scratchData == h.scratch.sortedIds.data()); + CHECK(traceData == h.trace.candidates.data()); + CHECK(scratchCapacity == h.scratch.sortedIds.capacity()); + CHECK(traceCapacity == h.trace.candidates.capacity()); + }; + // Overlapping failures also verify the documented validation precedence. + check(StepStatus::InvalidProfile, invalidObserver, invalidProfile, + h.memory.lastStepTime - 1, oversized); + check(StepStatus::InvalidObserver, invalidObserver, p, + h.memory.lastStepTime - 1, oversized); + check(StepStatus::TimeWentBackwards, observer, p, h.memory.lastStepTime - 1, oversized); + check(StepStatus::InsufficientCapacity, observer, p, now, oversized); + DecisionTrace shortTrace; + CHECK(Step(observer, frame, p, now, h.memory, h.scratch, h.output, + &shortTrace) == StepStatus::InsufficientCapacity); + CHECK(shortTrace.candidates.empty() && shortTrace.candidates.capacity() == 0); + CHECK(test::SameMemory(before.memory, h.memory) && test::SameDecision(before.output, h.output)); + CHECK(before.scratch.sortedIds == h.scratch.sortedIds && SameTrace(before.trace, h.trace)); + CHECK(scratchData == h.scratch.sortedIds.data() && traceData == h.trace.candidates.data()); + CHECK(scratchCapacity == h.scratch.sortedIds.capacity() && traceCapacity == h.trace.candidates.capacity()); +} + +bool ValidTransition(const test::Harness& h, const ObserverMemory& before, + const AttentionProfile& p, TimeMs now, + std::span frame) +{ + bool ok = test::ConsistentStep(h, p) && h.output.previous == before.current && + h.memory.current == h.output.selected && h.memory.hasStepped && + h.memory.lastStepTime == now && h.trace.candidates.size() == frame.size(); + const TimeMs focus = h.output.selected == 0 ? 0 + : h.output.selected == before.current ? before.focusStartTime : now; + const auto kind = h.output.selected == 0 + ? (before.current == 0 ? interest::DecisionKind::None : interest::DecisionKind::Clear) + : (before.current == 0 ? interest::DecisionKind::Acquire + : before.current == h.output.selected ? interest::DecisionKind::Keep : interest::DecisionKind::Switch); + ok = ok && h.memory.focusStartTime == focus && h.output.kind == kind; + TimeMs dwell = 0; + if (before.current != 0 && now >= before.focusStartTime) { + const auto elapsed = static_cast(now) - + static_cast(before.focusStartTime); + if (elapsed < static_cast(p.minDwellMs)) { + dwell = p.minDwellMs - static_cast(elapsed); + } + } + ok = ok && h.trace.dwellRemainingMs == dwell && + h.trace.acquireThreshold == p.acquireThreshold && + h.trace.releaseThreshold == p.releaseThreshold && + h.trace.switchMargin == p.switchMargin && h.trace.overrideMargin == p.overrideMargin; + bool foundSelected = h.output.selected == 0 && h.output.rawScore == 0.f; + foundSelected = foundSelected && h.output.effectiveScore == 0.f; + for (std::size_t i = 0; i < frame.size(); ++i) { + const auto& c = frame[i]; + const auto& e = h.trace.candidates[i]; + const auto count = std::count_if(frame.begin(), frame.end(), + [&](const auto& other) { return other.id == c.id; }); + ok = ok && e.id == c.id && SameFloat(e.attraction, c.attraction) && + SameFloat(e.salience, c.salience) && SameFloat(e.perceptionStrength, c.perceptionStrength); + if (c.id == 0) { ok = ok && e.eligibility == Eligibility::InvalidId; } + else if (count > 1) { ok = ok && e.eligibility == Eligibility::DuplicateId; } + const float computed[]{e.distanceGate, e.angleGate, e.gate, e.affinity, + e.preference, e.rawScore}; + for (float value : computed) { + ok = ok && std::isfinite(value) && value >= 0.f && value <= 1.f; + } + ok = ok && e.eventTimeMs == c.eventTimeMs && SameFloat(e.eventStrength, c.eventStrength) && + std::isfinite(e.effectiveScore) && e.effectiveScore >= 0.f && e.effectiveScore <= 1.f && + std::isfinite(e.eventNovelty) && e.eventNovelty >= 0 && e.eventNovelty <= 1 && + std::isfinite(e.noveltyContribution) && e.noveltyContribution >= 0 && e.noveltyContribution <= 1; + if (e.eligibility == Eligibility::Eligible) { + ok = ok && e.effectiveScore == static_cast((std::min)( + 1.0, static_cast(e.rawScore) + e.noveltyContribution)); + } else { + ok = ok && e.effectiveScore == 0 && e.eventNovelty == 0 && e.noveltyContribution == 0; + } + if (e.eligibility != Eligibility::Eligible) { + ok = ok && e.rawScore == 0 && (h.output.selected == 0 || e.id != h.output.selected); + } else if (e.id == h.output.selected) { + foundSelected = h.output.rawScore == e.rawScore; + foundSelected = foundSelected && h.output.effectiveScore == e.effectiveScore; + } + } + return ok && foundSelected; +} + +void Stream(std::uint64_t seed, bool novelty, std::FILE* recording) +{ + ContextPreference contexts[2]{}; + contexts[1].requiredTags = TagMask{1}; + contexts[1].excludedTags = TagMask{2}; + contexts[1].affinityTags = TagMask{1} << 63; + contexts[1].tagAffinity[63] = 0.875f; + AttentionProfile p = test::TemporalProfile(contexts); + p.salienceWeight = 1.f; p.affinityWeight = 1.f; + p.noveltyBeta = novelty ? 0.75f : 0.f; + test::Random rng(seed), shuffler(seed ^ 0xABCDEFu); + std::array pool{}; + for (std::size_t i = 0; i < pool.size(); ++i) { pool[i] = test::Scored(i + 1, 0.625f); } + test::Harness h, replay, shuffled, untraced; + for (auto* state : {&h, &replay, &shuffled, &untraced}) { + Reserve(state->scratch, kCapacity); Reserve(state->trace, kCapacity); + } + const auto* scratchData = h.scratch.sortedIds.data(); + const auto* traceData = h.trace.candidates.data(); + const auto scratchCapacity = h.scratch.sortedIds.capacity(); + const auto traceCapacity = h.trace.candidates.capacity(); + std::uint64_t hash = 14695981039346656037ull; + std::array reasons{}; + std::array rejections{}; + int resets = 0, failures = 0, duplicates = 0, repeated = 0; + int events = 0; + CandidateId nextId = 65; + TimeMs now = -100000; + for (int step = 0; step < kSteps; ++step) { + const TimeMs delta = static_cast(rng.Below(401)); + if (delta == 0) { ++repeated; } + now += delta; + if (step % 997 == 0) { + for (auto* state : {&h, &replay, &shuffled, &untraced}) { ResetMemory(state->memory); } + ++resets; + } + // Replace one source's lifetime and update another source's facts. Events + // remain unchanged on other pool members across arbitrarily many steps. + auto& c = pool[static_cast(rng.Below(pool.size()))]; + c = test::Scored(nextId++, rng.Unit()); + c.salience = rng.Unit(); c.perceptionStrength = rng.Unit(); + c.spatial = static_cast(rng.Below(3)); + c.position = {static_cast(rng.Below(49)) - 24, static_cast(rng.Below(17)) - 8, 0}; + c.bearing = {rng.Unit() - 0.5f, rng.Unit() - 0.5f, 0.25f}; + c.tags = rng.Below(4) | (rng.Below(2) << 63); + c.enabled = rng.Below(8) != 0; + const auto age = static_cast(rng.Below(2001)); + const float strength = rng.Unit(); + const auto edit = rng.Below(40); + if (edit == 0) { c.attraction = std::numeric_limits::quiet_NaN(); } + if (edit == 1) { c.position.x = std::numeric_limits::infinity(); c.spatial = SpatialKind::Point; } + if (edit == 2) { c.perceptionStrength = 0; } + if (edit == 3) { c.spatial = static_cast(99); } + if (edit == 4) { c.id = 0; } + c.eventTimeMs = now - age; c.eventStrength = strength; + auto& changed = pool[static_cast(rng.Below(pool.size()))]; + changed.eventTimeMs = now; + changed.eventStrength = 0.5f; + // Invalid novelty event inputs are outside the temporal compatibility domain. + if (novelty && edit == 5) { c.eventTimeMs = now + 1; c.eventStrength = 1.f; } + if (novelty && edit == 6) { c.eventStrength = std::numeric_limits::quiet_NaN(); } + std::vector frame; + const auto count = static_cast(rng.Below(pool.size() + 1)); + frame.insert(frame.end(), pool.begin(), pool.begin() + static_cast(count)); + if (!frame.empty() && step % 11 == 0) { + const auto duplicate = frame.front(); + frame.push_back(duplicate); frame.push_back(duplicate); ++duplicates; + } + std::vector order(frame.size()); + for (std::size_t i = 0; i < order.size(); ++i) { order[i] = i; } + shuffler.Shuffle(order); + std::vector permuted; + for (auto i : order) { permuted.push_back(frame[i]); } + const auto observer = test::Observer(static_cast(rng.Below(2))); + p.minDwellMs = step % 3 == 0 ? 0 : 500; + const int checksBefore = test::State().failures; + if (step % 127 == 1) { InjectErrors(h, observer, p, now); ++failures; } + const auto before = h.memory; + CHECK(Step(observer, frame, p, now, h.memory, h.scratch, h.output, &h.trace) == StepStatus::Ok); + CHECK(Step(observer, frame, p, now, replay.memory, replay.scratch, replay.output, &replay.trace) == StepStatus::Ok); + CHECK(Step(observer, permuted, p, now, shuffled.memory, shuffled.scratch, shuffled.output, &shuffled.trace) == StepStatus::Ok); + CHECK(Step(observer, frame, p, now, untraced.memory, untraced.scratch, untraced.output) == StepStatus::Ok); + CHECK(ValidTransition(h, before, p, now, frame)); + CHECK(test::SameMemory(h.memory, replay.memory) && test::SameDecision(h.output, replay.output)); + CHECK(SameTrace(h.trace, replay.trace)); + CHECK(test::SameMemory(h.memory, shuffled.memory) && test::SameDecision(h.output, shuffled.output)); + CHECK(test::SamePolicyTrace(h.trace, shuffled.trace)); + CHECK(test::SameMemory(h.memory, untraced.memory) && test::SameDecision(h.output, untraced.output)); + for (std::size_t i = 0; i < order.size(); ++i) { + CHECK(SameEntry(h.trace.candidates[order[i]], shuffled.trace.candidates[i])); + } + CHECK(scratchData == h.scratch.sortedIds.data() && traceData == h.trace.candidates.data()); + CHECK(scratchCapacity == h.scratch.sortedIds.capacity() && traceCapacity == h.trace.candidates.capacity()); + ++reasons[static_cast(h.output.reason)]; + for (const auto& e : h.trace.candidates) { + ++rejections[static_cast(e.eligibility)]; + if (e.eventNovelty > 0) { ++events; } + if (!novelty && e.eligibility == Eligibility::Eligible) { CHECK(e.effectiveScore == e.rawScore); } + } + RecordStep(recording, hash, h); + if (checksBefore != test::State().failures) { + std::fprintf(stderr, "C18 seed=%llu novelty=%d step=%d time=%lld\n", + static_cast(seed), novelty ? 1 : 0, step, static_cast(now)); + return; // a bounded, directly reproducible first failure + } + } + CHECK(resets > 10 && failures > 90 && duplicates > 1000 && repeated > 10); + for (auto reason : {DecisionReason::HighestScore, DecisionReason::CurrentInvalid, + DecisionReason::BetterCandidate, DecisionReason::MinimumDwell}) { + CHECK(reasons[static_cast(reason)] > 0); + } + for (auto rejection : {Eligibility::Eligible, Eligibility::InvalidId, Eligibility::DuplicateId, + Eligibility::InvalidValue, Eligibility::InvalidGeometry, Eligibility::Disabled, + Eligibility::NotPerceived, Eligibility::MissingRequiredTag, + Eligibility::ExcludedTag, Eligibility::OutOfRange}) { + CHECK(rejections[static_cast(rejection)] > 0); + } + CHECK(events > kSteps); + if (novelty) { CHECK(rejections[static_cast(Eligibility::InvalidEvent)] > 0); } + std::printf(" C18 seed=%llu beta=%s steps=%d simulated_ms=%lld resets=%d error_batches=%d " + "triple_frames=%d repeated_times=%d transition_hash=%016llx\n", + static_cast(seed), novelty ? "0.75" : "0", kSteps, + static_cast(now + 100000), resets, failures, duplicates, repeated, + static_cast(hash)); +} + +std::FILE* recordingFile = nullptr; +void C18() +{ + for (std::uint64_t seed : {0xC180001ull, 0xC180002ull, 0xC180003ull, 0xC180004ull}) { + Stream(seed, false, recordingFile); + Stream(seed, true, nullptr); + } +} +} // namespace + +int main(int argc, char** argv) +{ + if (argc > 2) { std::fprintf(stderr, "usage: interest_c18_test [legacy-transition-output]\n"); return 1; } + if (argc == 2) { +#ifdef _MSC_VER + if (fopen_s(&recordingFile, argv[1], "wb") != 0) { recordingFile = nullptr; } +#else + recordingFile = std::fopen(argv[1], "wb"); +#endif + if (recordingFile == nullptr) { std::perror("C18 recording"); return 1; } + } + test::Run("C18 sustained candidate/event churn", C18); + if (recordingFile != nullptr) { CHECK(std::fclose(recordingFile) == 0); } + return test::Finish("interest_c18_test"); +} diff --git a/tests/step_novelty_test.cpp b/tests/step_novelty_test.cpp new file mode 100644 index 0000000..7bd9dd4 --- /dev/null +++ b/tests/step_novelty_test.cpp @@ -0,0 +1,474 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Checks timestamped novelty decay, event validation, geometric gating, saturation, +// temporal selection and deterministic replay over the same candidate streams. +// Alternative compositions live only in this test; the production policy is reused. +#include "test_support.hpp" + +#include +#include +#include + +namespace { +using interest::AttentionProfile; +using interest::CandidateId; +using interest::CandidateSnapshot; +using interest::ContextPreference; +using interest::DecisionKind; +using interest::Eligibility; +using interest::StepStatus; +using interest::TimeMs; +using test::Harness; +using test::Observer; +using test::Scored; + +AttentionProfile NoveltyProfile(std::span contexts) +{ + auto p = test::TemporalProfile(contexts); + p.minDwellMs = 0; + p.noveltyBeta = 0.75f; + return p; +} + +CandidateSnapshot Event(CandidateSnapshot c, TimeMs time, float strength = 1.f) +{ + c.eventTimeMs = time; + c.eventStrength = strength; + return c; +} + +void CheckStep(const Harness& h, const AttentionProfile& p, TimeMs now) +{ + CHECK(test::ConsistentStep(h, p)); + CHECK(h.memory.lastStepTime == now); + for (const auto& e : h.trace.candidates) { + if (e.eligibility != Eligibility::Eligible) { + CHECK(e.eventNovelty == 0 && + e.noveltyContribution == 0 && e.effectiveScore == 0); + continue; + } + CHECK(std::isfinite(e.rawScore) && e.rawScore >= 0 && e.rawScore <= 1); + CHECK(std::isfinite(e.effectiveScore) && e.effectiveScore >= 0 && e.effectiveScore <= 1); + double novelty = 0; + if (e.eventStrength > 0.f) { + CHECK(e.eventTimeMs <= now); + const auto age = static_cast(now) - + static_cast(e.eventTimeMs); + novelty = static_cast(e.eventStrength) * + std::exp2(-static_cast(age) / static_cast(p.noveltyHalfLifeMs)); + } + CHECK(e.eventNovelty == novelty && novelty >= 0 && novelty <= 1); + CHECK(e.noveltyContribution == static_cast(p.noveltyBeta) * + static_cast(e.gate) * novelty); + CHECK(e.effectiveScore == static_cast((std::min)( + 1.0, static_cast(e.rawScore) + e.noveltyContribution))); + if (e.id == h.output.selected) { + CHECK(h.output.rawScore == e.rawScore && h.output.effectiveScore == e.effectiveScore); + } + } + if (h.output.selected == 0) { + CHECK(h.output.rawScore == 0 && h.output.effectiveScore == 0); + } +} + +void C14UnchangedEventNeverRetriggers() +{ + const ContextPreference contexts[1]{}; + const auto p = NoveltyProfile(contexts); + const CandidateSnapshot one[]{Event(Scored(1, 0.25f), -1000)}; + Harness h; + const double expected[]{1, 0.5, 0.25, 0.125, 0.0625}; + for (int i = 0; i < 5; ++i) { + const TimeMs now = -1000 + static_cast(i) * 1000; + CHECK(h.Run(Observer(), one, p, now) == StepStatus::Ok); + CHECK(h.Find(1)->eventNovelty == expected[i]); + CHECK(h.Find(1)->noveltyContribution == 0.75 * expected[i]); + CheckStep(h, p, now); + const auto before = h.memory; + const auto entry = *h.Find(1); + CHECK(h.Run(Observer(), one, p, now) == StepStatus::Ok); + CHECK(test::SameMemory(h.memory, before)); + CHECK(test::SameEntry(*h.Find(1), entry)); + if (i == 2) { + CHECK_DECISION(h, Keep, AboveReleaseThreshold, 1); + } + if (i == 3) { + CHECK(h.output.kind == DecisionKind::None); // first call cleared; repeat stays clear + } + } + CHECK(h.output.selected == 0); + // First arrival is aged from the host event, not reception, even after ResetMemory. + Harness late; + CHECK(late.Run(Observer(), one, p, 3000) == StepStatus::Ok); + CHECK(late.Find(1)->eventNovelty == 0.0625 && late.output.selected == 0); + interest::ResetMemory(late.memory); + CHECK(late.Run(Observer(), one, p, 3000) == StepStatus::Ok); + CHECK(late.Find(1)->eventNovelty == 0.0625); + // Absent event contributes zero immediately; strength zero ignores even future time. + const CandidateSnapshot absent[]{Event(Scored(1, 0.25f), + std::numeric_limits::max(), 0.f)}; + CHECK(late.Run(Observer(), absent, p, 3000) == StepStatus::Ok); + CHECK(late.Find(1)->eventNovelty == 0 && late.Find(1)->effectiveScore == 0.25f); + CHECK(late.Run(Observer(), one, p, 4000) == StepStatus::Ok); + CHECK(late.Find(1)->eventNovelty == 0.03125); // removing/resubmitting is not a new event + + // Population changes cannot alter absolute event age; there is no candidate cache. + std::vector crowd; + for (CandidateId id = 1; id <= 40; ++id) { + crowd.push_back(Event(Scored(id, 0.1f), 0, 0.125f)); + } + Harness bounded; + CHECK(bounded.Run(Observer(), crowd, p, 0) == StepStatus::Ok); + CHECK(bounded.Run(Observer(), crowd, p, 1000) == StepStatus::Ok); + CHECK(bounded.Find(40)->eventNovelty == 0.0625); + const CandidateSnapshot returning[]{crowd.back()}; + CHECK(bounded.Run(Observer(), returning, p, 2000) == StepStatus::Ok); + CHECK(bounded.Find(40)->eventNovelty == 0.03125); + for (auto& c : crowd) { c.id += 40; } + CHECK(bounded.Run(Observer(), crowd, p, 3000) == StepStatus::Ok); + CHECK(bounded.Run(Observer(), returning, p, 4000) == StepStatus::Ok); + CHECK(bounded.Find(40)->eventNovelty == 0.0078125); + std::printf(" C14 novelty=1,0.5,0.25,0.125,0.0625 over 4s; no reception/population/reset spike\n"); +} + +void EventValidationAndExtremeAges() +{ + constexpr TimeMs lo = std::numeric_limits::min(); + constexpr TimeMs hi = std::numeric_limits::max(); + const ContextPreference contexts[1]{}; + auto p = NoveltyProfile(contexts); + Harness h; + const CandidateSnapshot extreme[]{Event(Scored(1, 0.25f), lo)}; + CHECK(h.Run(Observer(), extreme, p, lo) == StepStatus::Ok); + CHECK(h.Find(1)->eventNovelty == 1); + CHECK(h.Run(Observer(), extreme, p, hi) == StepStatus::Ok); + CHECK(h.Find(1)->eventNovelty == 0); + p.noveltyHalfLifeMs = hi; + Harness slow; + CHECK(slow.Run(Observer(), extreme, p, hi) == StepStatus::Ok); + CHECK(test::Near(slow.Find(1)->eventNovelty, 0.25, 1e-14)); + p.noveltyHalfLifeMs = 1; + const CandidateSnapshot negative[]{Event(Scored(1, 0.25f), -10)}; + CHECK(h.Run(Observer(), {}, p, hi) == StepStatus::Ok); + Harness fast; + CHECK(fast.Run(Observer(), negative, p, -9) == StepStatus::Ok); + CHECK(fast.Find(1)->eventNovelty == 0.5); + // Whole/split observation schedules give exactly the same age-based decay. + Harness split; + CHECK(split.Run(Observer(), negative, p, -10) == StepStatus::Ok); + CHECK(split.Run(Observer(), negative, p, -9) == StepStatus::Ok); + CHECK(split.Find(1)->eventNovelty == fast.Find(1)->eventNovelty); + + p = NoveltyProfile(contexts); + p.minDwellMs = 10000; + const CandidateSnapshot good[]{Event(Scored(1, 0.8f), 0)}; + const float badStrengths[]{-0.1f, 1.01f, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}; + for (float beta : {0.f, 0.75f}) { + p.noveltyBeta = beta; + for (float strength : badStrengths) { + Harness invalid; + CHECK(invalid.Run(Observer(), good, p, 0) == StepStatus::Ok); + const CandidateSnapshot bad[]{Event(Scored(1, 0.8f), 0, strength)}; + CHECK(invalid.Run(Observer(), bad, p, 100) == StepStatus::Ok); + CHECK(invalid.Find(1)->eligibility == Eligibility::InvalidEvent); + CHECK_DECISION(invalid, Clear, CurrentInvalid, 0); + CheckStep(invalid, p, 100); + } + for (TimeMs future : {TimeMs{101}, hi}) { + Harness invalid; + CHECK(invalid.Run(Observer(), good, p, 0) == StepStatus::Ok); + const CandidateSnapshot bad[]{Event(Scored(1, 0.8f), future), Scored(2, 0.625f)}; + CHECK(invalid.Run(Observer(), bad, p, 100) == StepStatus::Ok); + CHECK(invalid.Find(1)->eligibility == Eligibility::InvalidEvent); + CHECK_DECISION(invalid, Switch, CurrentInvalid, 2); + CHECK(invalid.trace.dwellRemainingMs == 9900); + CheckStep(invalid, p, 100); + } + } + const CandidateSnapshot duplicates[]{Event(Scored(1, 1.f), hi), + Event(Scored(1, 1.f), lo), Event(Scored(1, 1.f), 0)}; + Harness duplicated; + CHECK(duplicated.Run(Observer(), duplicates, p, 100) == StepStatus::Ok); + for (const auto& e : duplicated.trace.candidates) { + CHECK(e.eligibility == Eligibility::DuplicateId); + } + CHECK(duplicated.memory.current == 0); + CHECK(std::string_view(interest::ToString(Eligibility::InvalidEvent)) == "InvalidEvent"); +} + +void OrdinaryPolicyCapturesAndReturns() +{ + const ContextPreference contexts[1]{}; + auto p = NoveltyProfile(contexts); + p.minDwellMs = 500; + for (TimeMs interval : {TimeMs{50}, TimeMs{200}}) { + Harness h, b2; + auto off = p; off.noveltyBeta = 0; + TimeMs captured = -1; + TimeMs returned = -1; + int switches = 0; + for (TimeMs now = 0; now <= 5000; now += interval) { + const CandidateSnapshot frame[]{Scored(1, 0.625f), now < 150 + ? Scored(2, 0.25f) : Event(Scored(2, 0.25f), 150)}; + CHECK(h.Run(Observer(), frame, p, now) == StepStatus::Ok); + CHECK(b2.Run(Observer(), frame, off, now) == StepStatus::Ok); + CHECK(b2.output.selected == 1); + CheckStep(h, p, now); + if (h.output.kind == DecisionKind::Switch) { + ++switches; + if (h.output.selected == 2) { + captured = now; + CHECK_DECISION(h, Switch, DwellOverride, 2); + } else { + returned = now; + CHECK_DECISION(h, Switch, BetterCandidate, 1); + } + } + } + CHECK(captured >= 150 && captured - 150 < interval); + CHECK(returned >= 1700 && returned <= 2000 && switches == 2); + std::printf(" event capture/normal return %lldHz: latency=%lldms return=%lldms " + "switches=%d; B2 misses event\n", static_cast(1000 / interval), + static_cast(captured - 150), static_cast(returned), switches); + } +} + +void SpatialGatingComparison() +{ + const ContextPreference contexts[1]{}; + const auto p = NoveltyProfile(contexts); + auto alternativeProfile = p; + alternativeProfile.noveltyBeta = 0; + const CandidateSnapshot incumbent[]{Scored(1, 0.6f)}; + struct Row { const char* name; CandidateSnapshot c; bool eligible; bool takeFull; bool takeLess; }; + const Row rows[]{ + {"front positive control", test::PointAt(2, 5, 0, 0.4f, 0), true, true, true}, + {"far 19/20", test::PointAt(2, 19, 0, 0.4f, 0), true, false, true}, + {"behind", test::PointAt(2, -5, 0, 0.4f, 0), true, false, true}, + {"at cutoff", test::PointAt(2, 20, 0, 0.4f, 0), true, false, true}, + {"outside cutoff", test::PointAt(2, 21, 0, 0.4f, 0), false, false, false}, + {"behind bearing", [] { auto c = Scored(2, 0.4f); c.spatial = interest::SpatialKind::Bearing; + c.bearing = {-1.f, 0, 0}; return c; }(), true, false, true}, + {"nonspatial positive control", Scored(2, 0.4f), true, true, true} + }; + int falseFull = 0; + int falseLess = 0; + for (const auto& row : rows) { + const CandidateSnapshot frame[]{incumbent[0], Event(row.c, 100)}; + Harness full, less; + CHECK(full.Run(Observer(), incumbent, p, 0) == StepStatus::Ok); + CHECK(less.Run(Observer(), incumbent, alternativeProfile, 0) == StepStatus::Ok); + CHECK(full.Run(Observer(), frame, p, 100) == StepStatus::Ok); + CheckStep(full, p, 100); + const auto& e = *full.Find(2); + CHECK((e.eligibility == Eligibility::Eligible) == row.eligible); + // Test-only composition from the same raw score and decayed event. Feed + // synthetic scores through the actual temporal policy; no second policy/scorer API. + const float lessScore = static_cast((std::min)(1.0, static_cast(e.rawScore) + + static_cast(p.noveltyBeta) * static_cast(e.perceptionStrength) * e.eventNovelty)); + CandidateSnapshot scores[]{Scored(1, full.Find(1)->effectiveScore), Scored(2, lessScore)}; + scores[1].enabled = row.eligible; + CHECK(less.Run(Observer(), scores, alternativeProfile, 100) == StepStatus::Ok); + CHECK((full.output.selected == 2) == row.takeFull); + CHECK((less.output.selected == 2) == row.takeLess); + const bool distractor = row.takeLess && !row.takeFull; + falseFull += distractor && full.output.selected == 2 ? 1 : 0; + falseLess += distractor && less.output.selected == 2 ? 1 : 0; + std::printf(" spatial %-26s gate=%.6f raw=%.6f full=%.6f less=%.6f " + "selected=%llu/%llu\n", row.name, static_cast(e.gate), + static_cast(e.rawScore), static_cast(e.effectiveScore), + static_cast(lessScore), static_cast(full.output.selected), + static_cast(less.output.selected)); + } + CHECK(falseFull == 0 && falseLess == 4); + std::printf(" labelled low-access distractors: full=%d/4 less=%d/4; reversing labels " + "makes full gating miss 4 relevant events (a real tradeoff)\n", falseFull, falseLess); +} + +void SaturationAndPolicyBoundaries() +{ + const ContextPreference contexts[1]{}; + auto p = NoveltyProfile(contexts); + p.noveltyBeta = 1; + const CandidateSnapshot both[]{Event(Scored(1, 1.f), 0), Event(Scored(2, 0.75f), 0)}; + Harness h; + CHECK(h.Run(Observer(), std::span(both + 1, 1), p, 0) == StepStatus::Ok); + CHECK(h.Run(Observer(), both, p, 0) == StepStatus::Ok); + CHECK(h.Find(1)->effectiveScore == 1 && h.Find(2)->effectiveScore == 1); + CHECK(static_cast(h.Find(1)->rawScore) + h.Find(1)->noveltyContribution == 2); + CHECK_DECISION(h, Keep, InsufficientAdvantage, 2); // saturation tie cannot switch incumbent + Harness fresh; + CHECK(fresh.Run(Observer(), both, p, 0) == StepStatus::Ok); + CHECK(fresh.output.selected == 1); // tie acquisition goes to lower ID + CheckStep(h, p, 0); + + p = NoveltyProfile(contexts); + p.minDwellMs = 10000; + const CandidateSnapshot current[]{Scored(1, 0.625f)}; + Harness protectedTarget; + CHECK(protectedTarget.Run(Observer(), current, p, 0) == StepStatus::Ok); + // A weaker event produces a +.125 lead: switch margin passes but dwell holds. + const CandidateSnapshot modest[]{current[0], Event(Scored(2, 0.375f), 100, 0.5f)}; + CHECK(protectedTarget.Run(Observer(), modest, p, 100) == StepStatus::Ok); + CHECK_DECISION(protectedTarget, Keep, MinimumDwell, 1); + const CandidateSnapshot strong[]{current[0], Event(Scored(2, 0.375f), 100)}; + CHECK(protectedTarget.Run(Observer(), strong, p, 100) == StepStatus::Ok); + CHECK_DECISION(protectedTarget, Switch, DwellOverride, 2); + // Strong event never fabricates perception or rescues an invalid gate. + auto hidden = strong[1]; hidden.perceptionStrength = 0; + const CandidateSnapshot invalid[]{current[0], hidden}; + CHECK(protectedTarget.Run(Observer(), invalid, p, 100) == StepStatus::Ok); + CHECK_DECISION(protectedTarget, Switch, CurrentInvalid, 1); + CheckStep(protectedTarget, p, 100); + + // A score lead alone does not bypass acquire, even if it passes overrideMargin. + p.acquireThreshold = 0.75f; + Harness threshold; + const CandidateSnapshot first[]{Scored(1, 0.8f)}; + CHECK(threshold.Run(Observer(), first, p, 0) == StepStatus::Ok); + const CandidateSnapshot below[]{Scored(1, 0.375f), Event(Scored(2, 0.25f), 100, 0.5f)}; + CHECK(threshold.Run(Observer(), below, p, 100) == StepStatus::Ok); + CHECK_DECISION(threshold, Keep, InsufficientAdvantage, 1); + CheckStep(threshold, p, 100); +} + +void ErrorsPreserveAllBuffers() +{ + const ContextPreference contexts[1]{}; + const auto p = NoveltyProfile(contexts); + const CandidateSnapshot frame[]{Event(Scored(1, 0.4f), 0)}; + Harness h; + CHECK(h.Run(Observer(), frame, p, 1000) == StepStatus::Ok); + const Harness before = h; + const auto* scratchData = h.scratch.sortedIds.data(); + const auto* traceData = h.trace.candidates.data(); + const auto scratchCapacity = h.scratch.sortedIds.capacity(); + const auto traceCapacity = h.trace.candidates.capacity(); + const auto unchanged = [&] { + CHECK(test::SameMemory(h.memory, before.memory) && test::SameDecision(h.output, before.output)); + CHECK(test::SamePolicyTrace(h.trace, before.trace)); + CHECK(h.trace.candidates.size() == before.trace.candidates.size()); + CHECK(test::SameEntry(h.trace.candidates[0], before.trace.candidates[0])); + CHECK(h.scratch.sortedIds == before.scratch.sortedIds && h.scratch.sortedIds.data() == scratchData); + CHECK(h.trace.candidates.data() == traceData && h.trace.candidates.capacity() == traceCapacity); + CHECK(h.scratch.sortedIds.capacity() == scratchCapacity); + }; + for (float beta : {-1.f, 1.01f, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + auto bad = p; bad.noveltyBeta = beta; + CHECK(interest::ValidateProfile(bad) == StepStatus::InvalidProfile); + CHECK(h.Run(Observer(), frame, bad, 2000) == StepStatus::InvalidProfile); + unchanged(); + } + for (TimeMs halfLife : {TimeMs{0}, TimeMs{-1}, std::numeric_limits::min()}) { + auto bad = p; bad.noveltyHalfLifeMs = halfLife; + CHECK(interest::ValidateProfile(bad) == StepStatus::InvalidProfile); + CHECK(h.Run(Observer(), frame, bad, 2000) == StepStatus::InvalidProfile); + unchanged(); + } + auto invalid = Observer(); invalid.referenceForward = {}; + CHECK(h.Run(invalid, frame, p, 2000) == StepStatus::InvalidObserver); + unchanged(); + CHECK(h.Run(Observer(), frame, p, 999) == StepStatus::TimeWentBackwards); + unchanged(); + const std::vector oversized(scratchCapacity + 1, frame[0]); + CHECK(interest::Step(Observer(), oversized, p, 2000, h.memory, h.scratch, h.output, + &h.trace) == StepStatus::InsufficientCapacity); + unchanged(); + interest::DecisionTrace shortTrace; + CHECK(interest::Step(Observer(), frame, p, 2000, h.memory, h.scratch, h.output, + &shortTrace) == StepStatus::InsufficientCapacity); + CHECK(shortTrace.candidates.empty() && shortTrace.candidates.capacity() == 0); + unchanged(); +} + +void ReplayPermutationsAndAblation() +{ + const ContextPreference contexts[1]{}; + auto p = NoveltyProfile(contexts); + p.minDwellMs = 500; + auto off = p; off.noveltyBeta = 0; + Harness h, shuffled, untraced, noveltyOff, withoutEvents; + test::Random rng(0xC140015); + TimeMs now = -10000; + for (int step = 0; step < 2000; ++step) { + now += static_cast(rng.Below(201)); + std::vector frame; + for (int i = 0; i < 40; ++i) { + auto c = Event(Scored(1 + rng.Below(64), rng.Unit()), + now - static_cast(rng.Below(10001)), rng.Unit()); + c.spatial = static_cast(rng.Below(3)); + c.position = {static_cast(rng.Below(41)) - 20, 0, 0}; + c.bearing = {rng.Below(2) == 0 ? -1.f : 1.f, 0, 0}; + c.perceptionStrength = rng.Unit(); + c.enabled = rng.Below(20) != 0; + frame.push_back(c); + } + Harness replay; + replay.memory = h.memory; + CHECK(h.Run(Observer(), frame, p, now) == StepStatus::Ok); + CHECK(replay.Run(Observer(), frame, p, now) == StepStatus::Ok); + CHECK(test::SameMemory(h.memory, replay.memory) && test::SameDecision(h.output, replay.output)); + CheckStep(h, p, now); + interest::Reserve(untraced.scratch, frame.size()); + CHECK(interest::Step(Observer(), frame, p, now, untraced.memory, untraced.scratch, + untraced.output) == StepStatus::Ok); + CHECK(test::SameMemory(h.memory, untraced.memory) && test::SameDecision(h.output, untraced.output)); + CHECK(noveltyOff.Run(Observer(), frame, off, now) == StepStatus::Ok); + auto absent = frame; + for (auto& c : absent) { c.eventStrength = 0; } + CHECK(withoutEvents.Run(Observer(), absent, off, now) == StepStatus::Ok); + CHECK(test::SameMemory(noveltyOff.memory, withoutEvents.memory)); + CHECK(test::SameDecision(noveltyOff.output, withoutEvents.output)); + CHECK(test::SamePolicyTrace(noveltyOff.trace, withoutEvents.trace)); + rng.Shuffle(frame); + CHECK(shuffled.Run(Observer(), frame, p, now) == StepStatus::Ok); + CHECK(test::SameMemory(h.memory, shuffled.memory) && test::SameDecision(h.output, shuffled.output)); + CHECK(test::SamePolicyTrace(h.trace, shuffled.trace)); + for (const auto& e : shuffled.trace.candidates) { + if (e.eligibility == Eligibility::Eligible) { + CHECK(test::SameEntry(e, *h.Find(e.id))); + } + } + } + std::printf(" 2000 x 40 event inputs: copy replay, shuffled replay, trace off, beta-zero " + "B2 decision/memory ablation identical\n"); +} + +void EnableAndResetPreserveEventAge() +{ + const ContextPreference contexts[1]{}; + auto p = NoveltyProfile(contexts); + p.noveltyBeta = 0; + const CandidateSnapshot frame[]{Event(Scored(1, 0.8f), 0)}; + Harness h; + CHECK(h.Run(Observer(), frame, p, 0) == StepStatus::Ok); + CHECK(h.Run(Observer(), frame, p, 2000) == StepStatus::Ok); + CHECK(h.Find(1)->effectiveScore == 0.8f && h.Find(1)->eventNovelty == 0.25); + p.noveltyBeta = 0.75f; + CHECK(h.Run(Observer(), frame, p, 2000) == StepStatus::Ok); + CHECK(h.Find(1)->effectiveScore == 0.9875f && h.Find(1)->eventNovelty == 0.25); + interest::ResetMemory(h.memory); + CHECK(h.Run(Observer(), frame, p, 2000) == StepStatus::Ok); + CHECK(h.Find(1)->effectiveScore == 0.9875f && h.Find(1)->eventNovelty == 0.25); + CheckStep(h, p, 2000); + std::printf(" novelty age survives enable/reset; observer=%zu candidate=%zu trace=%zu bytes\n", + sizeof(interest::ObserverMemory), sizeof(CandidateSnapshot), sizeof(interest::CandidateTrace)); +} + +} // namespace + +int main() +{ + test::Run("C14 unchanged timestamp decays without retrigger", C14UnchangedEventNeverRetriggers); + test::Run("event validation and extreme signed ages", EventValidationAndExtremeAges); + test::Run("event capture and ordinary-policy return", OrdinaryPolicyCapturesAndReturns); + test::Run("spatial versus perception-only novelty", SpatialGatingComparison); + test::Run("saturation and preserved temporal boundaries", SaturationAndPolicyBoundaries); + test::Run("errors preserve novelty traces and every buffer", ErrorsPreserveAllBuffers); + test::Run("copy replay, permutations, trace off and novelty-off", ReplayPermutationsAndAblation); + test::Run("enable and reset preserve absolute event age", EnableAndResetPreserveEventAge); + return test::Finish("interest_novelty_test"); +} diff --git a/tests/step_observers_test.cpp b/tests/step_observers_test.cpp new file mode 100644 index 0000000..3628289 --- /dev/null +++ b/tests/step_observers_test.cpp @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Fifty similar observers share a stimulus stream; host profiles supply explicit variation. + +#include "test_support.hpp" + +#include +#include +#include +#include + +namespace { + +using interest::AttentionProfile; +using interest::CandidateId; +using interest::CandidateSnapshot; +using interest::ContextPreference; +using interest::DecisionKind; +using interest::DecisionReason; +using interest::StepStatus; +using interest::TimeMs; +using test::Harness; +using test::Scored; + +constexpr int kObservers = 50; +constexpr TimeMs kInterval = 50; // 20 Hz +constexpr TimeMs kDuration = 12000; + +// A (id 1) rises from 0.40 to a 0.52 plateau at 4-6 s, then falls to 0.30 by 10 s. B (id 2) +// appears at 5 s at 0.45 and rises to 0.70 by 9 s. Values change once per 50 ms cell. +std::vector Stimulus(TimeMs t) +{ + const TimeMs cell = t - t % 50; + const auto ramp = [cell](TimeMs from, TimeMs to, float low, float high) { + return low + (high - low) * static_cast(cell - from) / + static_cast(to - from); + }; + const float a = cell < 4000 ? ramp(0, 4000, 0.40f, 0.52f) + : cell < 6000 ? 0.52f + : cell < 10000 ? ramp(6000, 10000, 0.52f, 0.30f) + : 0.30f; + std::vector candidates{Scored(1, a)}; + if (cell >= 5000) { + candidates.push_back(Scored(2, cell < 9000 ? ramp(5000, 9000, 0.45f, 0.70f) : 0.70f)); + } + return candidates; +} + +// What the host would derive once per character from its stored variation seed, and keep +// in that character's profile. Base values are test::TemporalProfile's. +AttentionProfile VariedProfile(std::span contexts, std::uint64_t seed) +{ + test::Random rng(seed); + AttentionProfile profile = test::TemporalProfile(contexts); + profile.acquireThreshold = 0.47f + 0.06f * rng.Unit(); + profile.releaseThreshold = profile.acquireThreshold - 0.05f - 0.05f * rng.Unit(); + profile.switchMargin = 0.1f + 0.05f * rng.Unit(); + profile.minDwellMs = 300 + static_cast(400.f * rng.Unit()); + return profile; +} + +struct Change { + TimeMs time; + CandidateId selected; + DecisionReason reason; + bool operator==(const Change&) const = default; +}; +using Timeline = std::vector; + +struct Member { + interest::ObserverId id; + AttentionProfile profile; + TimeMs phase; // the host's evaluation offset within the update interval +}; + +struct Population { + std::vector timelines; + bool consistent = true; +}; + +Population RunPopulation(const std::vector& observers) +{ + Population result; + for (const Member& o : observers) { + Harness h; + Timeline timeline; + interest::ObserverSnapshot snapshot = test::Observer(); + snapshot.id = o.id; + for (TimeMs now = o.phase; now < kDuration; now += kInterval) { + const std::vector candidates = Stimulus(now); + if (h.Run(snapshot, candidates, o.profile, now) != StepStatus::Ok || + !test::ConsistentStep(h, o.profile)) { + result.consistent = false; + } + if (h.output.kind != DecisionKind::Keep && h.output.kind != DecisionKind::None) { + timeline.push_back({now, h.output.selected, h.output.reason}); + } + } + result.timelines.push_back(timeline); + } + return result; +} + +struct Summary { + int distinctChoices = 0; // distinct sequences of selected IDs + int distinctTimelines = 0; // distinct sequences of (time, id, reason) + int largestSimultaneous = 0; // most observers changing target at the same time + TimeMs firstAcquireSpread = 0; + int neverAcquiredA = 0; +}; + +Summary Summarize(const Population& population) +{ + std::vector> choices; + std::vector timelines; + std::map changesAt; + TimeMs earliest = kDuration; + TimeMs latest = 0; + Summary summary; + for (const Timeline& timeline : population.timelines) { + std::vector sequence; + bool acquiredA = false; + for (const Change& change : timeline) { + sequence.push_back(change.selected); + ++changesAt[change.time]; + acquiredA = acquiredA || change.selected == 1; + } + summary.neverAcquiredA += acquiredA ? 0 : 1; + if (!timeline.empty()) { + earliest = std::min(earliest, timeline.front().time); + latest = std::max(latest, timeline.front().time); + } + if (std::find(choices.begin(), choices.end(), sequence) == choices.end()) { + choices.push_back(sequence); + } + if (std::find(timelines.begin(), timelines.end(), timeline) == timelines.end()) { + timelines.push_back(timeline); + } + } + for (const auto& entry : changesAt) { + summary.largestSimultaneous = std::max(summary.largestSimultaneous, entry.second); + } + summary.distinctChoices = static_cast(choices.size()); + summary.distinctTimelines = static_cast(timelines.size()); + summary.firstAcquireSpread = latest >= earliest ? latest - earliest : 0; + return summary; +} + +void Print(const char* condition, const Summary& s) +{ + std::printf(" %-34s %8d %10d %13d %12lld %10d\n", condition, s.distinctChoices, + s.distinctTimelines, s.largestSimultaneous, + static_cast(s.firstAcquireSpread), s.neverAcquiredA); +} + +void FiftyObservers() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile shared = test::TemporalProfile(contexts); + std::vector identical; + std::vector staggered; + std::vector varied; + std::vector variedStaggered; + std::vector renumbered; + for (int k = 0; k < kObservers; ++k) { + const auto id = static_cast(k + 1); + const auto phase = static_cast(k) % kInterval; // 0..49 ms + const AttentionProfile profile = + VariedProfile(contexts, 0xC11B0000u + static_cast(k)); + identical.push_back({id, shared, 0}); + staggered.push_back({id, shared, phase}); + varied.push_back({id, profile, 0}); + variedStaggered.push_back({id, profile, phase}); + renumbered.push_back({static_cast(9000 - k), profile, 0}); + } + + const Population lockstep = RunPopulation(identical); + const Population phased = RunPopulation(staggered); + const Population diverse = RunPopulation(varied); + const Population both = RunPopulation(variedStaggered); + const Population again = RunPopulation(varied); + const Population renamed = RunPopulation(renumbered); + CHECK(lockstep.consistent && phased.consistent && diverse.consistent && both.consistent); + + std::printf(" %-34s %8s %10s %13s %12s %10s\n", "condition (50 observers)", "choices", + "timelines", "simultaneous", "spread ms", "never A"); + const Summary a = Summarize(lockstep); + const Summary b = Summarize(phased); + const Summary c = Summarize(diverse); + const Summary d = Summarize(both); + Print("identical profiles, synchronous", a); + Print("identical profiles, staggered", b); + Print("varied profiles, synchronous", c); + Print("varied profiles, staggered", d); + + // Lockstep: one timeline shared by all 50. + CHECK(a.distinctTimelines == 1 && a.largestSimultaneous == kObservers); + CHECK(!lockstep.timelines[0].empty()); + + // Staggering desynchronizes timing only: each observer makes the synchronous choices, + // shifted by exactly its phase, because it sees the same 50 ms cells. + CHECK(b.distinctChoices == 1 && b.largestSimultaneous == 1); + bool shifted = true; + const Timeline& reference = lockstep.timelines[0]; + for (std::size_t k = 0; k < staggered.size(); ++k) { + const Timeline& timeline = phased.timelines[k]; + shifted = shifted && timeline.size() == reference.size(); + for (std::size_t i = 0; shifted && i < reference.size(); ++i) { + shifted = timeline[i].time == reference[i].time + staggered[k].phase && + timeline[i].selected == reference[i].selected && + timeline[i].reason == reference[i].reason; + } + } + CHECK(shifted); + CHECK(b.firstAcquireSpread == kInterval - 1); + + // Parameter variation changes choices as well as timing near the thresholds. A's 0.52 + // plateau is below some observers' acquire thresholds (0.47-0.53), so they first attend + // B, later; and A rises by only 0.03 per second, so first acquisitions spread over more + // than a second. + CHECK(c.distinctChoices > 1 && c.neverAcquiredA > 0 && c.neverAcquiredA < kObservers); + CHECK(c.largestSimultaneous < kObservers / 2); + CHECK(c.firstAcquireSpread > 1000); + + // The variation is deterministic and tied to the seeds, not to observer IDs. + CHECK(again.timelines == diverse.timelines); + CHECK(renamed.timelines == diverse.timelines); +} + +} // namespace + +int main() +{ + test::Run("C11b fifty observers", FiftyObservers); + return test::Finish("interest_observers_test"); +} diff --git a/tests/step_property_test.cpp b/tests/step_property_test.cpp new file mode 100644 index 0000000..d108a5b --- /dev/null +++ b/tests/step_property_test.cpp @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Property coverage complements the detailed scoring, temporal and novelty scenarios. +#include "test_support.hpp" + +#include +#include +#include + +namespace { +using interest::CandidateSnapshot; +using interest::ContextPreference; +using interest::Eligibility; +using interest::SpatialKind; +using interest::StepStatus; +using interest::TimeMs; +using test::Harness; +using test::Observer; +using test::Scored; + +template +V QuarterTurn(V v) { return V{-v.y, v.x, v.z}; } + +void GeometryTransformsPreserveScores() +{ + const ContextPreference contexts[1]{}; + auto p = test::Profile(contexts); + p.noveltyBeta = 0.75f; + test::Random rng(0xC180101); + for (int scene = 0; scene < 256; ++scene) { + auto observer = Observer(); + observer.position = {2, -4, 8}; + observer.referenceForward = {0.5f, 0.25f, 0.125f}; + std::array frame{}; + for (std::size_t i = 0; i < frame.size(); ++i) { + auto& c = frame[i]; + c = Scored(i + 1, rng.Unit()); + c.salience = rng.Unit(); c.perceptionStrength = rng.Unit(); + c.spatial = static_cast(i); + c.position = {static_cast(rng.Below(41)) - 20, + static_cast(rng.Below(17)) - 8, 4}; + c.bearing = {rng.Unit(), -0.5f, 0.25f}; + c.eventTimeMs = -1000; c.eventStrength = rng.Unit(); + c.maxDistance = scene % 2 == 0 ? 16.f : 0.f; + } + Harness original; + CHECK(original.Run(observer, frame, p, 0) == StepStatus::Ok); + for (int transform = 0; transform < 4; ++transform) { + auto moved = observer; + auto changed = frame; + auto profile = p; + if (transform == 0) { + moved.position.x += 256; moved.position.y -= 128; moved.position.z += 64; + for (auto& c : changed) { c.position.x += 256; c.position.y -= 128; c.position.z += 64; } + } else if (transform == 1) { + moved.position = QuarterTurn(moved.position); + moved.referenceForward = QuarterTurn(moved.referenceForward); + for (auto& c : changed) { c.position = QuarterTurn(c.position); c.bearing = QuarterTurn(c.bearing); } + } else if (transform == 3) { + // Observer identity is bookkeeping only: variation must come from the + // profile, never from the ID. + moved.id = 0xFFFF'FFFF'0000'0000u | static_cast(scene + 1); + } else { + // A change of host distance units; angular directions stay fixed. + moved.position.x *= 4; moved.position.y *= 4; moved.position.z *= 4; + profile.fullRange *= 4; profile.maxRange *= 4; + for (auto& c : changed) { + c.position.x *= 4; c.position.y *= 4; c.position.z *= 4; c.maxDistance *= 4; + } + } + Harness transformed; + CHECK(transformed.Run(moved, changed, profile, 0) == StepStatus::Ok); + CHECK(test::SameDecision(original.output, transformed.output)); + CHECK(test::SameMemory(original.memory, transformed.memory)); + CHECK(test::SamePolicyTrace(original.trace, transformed.trace)); + for (std::size_t i = 0; i < frame.size(); ++i) { + const auto& a = original.trace.candidates[i]; + const auto& b = transformed.trace.candidates[i]; + CHECK(a.eligibility == b.eligibility && a.distanceGate == b.distanceGate && + a.angleGate == b.angleGate && a.gate == b.gate && a.rawScore == b.rawScore && + a.noveltyContribution == b.noveltyContribution && a.effectiveScore == b.effectiveScore); + } + } + } +} + +void FiniteExtremesAndAdjacentCutoffs() +{ + const ContextPreference contexts[1]{}; + auto p = test::TemporalProfile(contexts); + p.minDwellMs = 0; p.noveltyBeta = 1; + p.angleFloor = 0; p.fullRange = 10; p.maxRange = 20; + auto c = test::PointAt(1, 20, 0, 1.f, 1.f); + c.eventStrength = 1; + for (bool hardCutoff : {false, true}) { + p.fullRange = hardCutoff ? 20.f : 10.f; + for (double x : {std::nextafter(20.0, 0.0), 20.0, std::nextafter(20.0, 21.0)}) { + c.position.x = x; + Harness h; + CHECK(h.Run(Observer(), std::span(&c, 1), p, 0) == StepStatus::Ok); + const auto& e = h.trace.candidates[0]; + CHECK(e.eligibility == (x > 20 ? Eligibility::OutOfRange : Eligibility::Eligible)); + if (x <= 20 && hardCutoff) { CHECK(e.gate == 1 && e.effectiveScore == 1); } + if (x == 20 && !hardCutoff) { CHECK(e.gate == 0 && e.effectiveScore == 0); } + CHECK(std::isfinite(e.rawScore) && std::isfinite(e.effectiveScore)); + } + } + // Finite vectors with the smallest/largest float magnitudes remain valid; + // normalization happens in double. Extreme finite weights must not overflow. + p.attractionWeight = p.salienceWeight = p.affinityWeight = (std::numeric_limits::max)(); + auto bearing = Scored(1, 1.f); + bearing.salience = 1; bearing.spatial = SpatialKind::Bearing; + for (float magnitude : {std::numeric_limits::denorm_min(), (std::numeric_limits::max)()}) { + auto observer = Observer(); observer.referenceForward = {magnitude, 0, 0}; + bearing.bearing = {magnitude, 0, 0}; + Harness h; + CHECK(h.Run(observer, std::span(&bearing, 1), p, 0) == StepStatus::Ok); + CHECK(h.trace.candidates[0].eligibility == Eligibility::Eligible); + CHECK(h.trace.candidates[0].angleGate == 1 && test::Near(h.output.rawScore, 5.0 / 6.0, 1e-7)); + } + auto observer = Observer(); + observer.position.x = -(std::numeric_limits::max)(); + c.position.x = (std::numeric_limits::max)(); + Harness overflow; + CHECK(overflow.Run(observer, std::span(&c, 1), p, 0) == StepStatus::Ok); + CHECK(overflow.trace.candidates[0].eligibility == Eligibility::OutOfRange); + CHECK(overflow.output.selected == 0 && overflow.output.rawScore == 0 && overflow.output.effectiveScore == 0); + // The diagnostic distance may be +inf for finite coordinates whose difference + // overflows. Scores remain finite; this does not promise finite input copies. + CHECK(std::isinf(overflow.trace.candidates[0].distance)); +} + +void AdjacentSaturationAndThresholds() +{ + const ContextPreference contexts[1]{}; + auto p = test::TemporalProfile(contexts); + p.minDwellMs = 0; p.noveltyBeta = 1; + // One float ulp below/at/above raw + event == 1, before and after rounding. + for (float strength : {std::nextafter(0.5f, 0.f), 0.5f, std::nextafter(0.5f, 1.f)}) { + auto c = Scored(1, 0.5f); c.eventStrength = strength; + Harness h; + CHECK(h.Run(Observer(), std::span(&c, 1), p, 0) == StepStatus::Ok); + const double sum = 0.5 + static_cast(strength); + CHECK(h.trace.candidates[0].noveltyContribution == static_cast(strength)); + CHECK(h.output.effectiveScore == static_cast((std::min)(1.0, sum))); + CHECK(h.output.effectiveScore == 1); // lower neighbor rounds to 1 too (ties to even) + } + // Effective-score thresholds, with exactly representable dyadic contributions. + for (float strength : {std::nextafter(0.25f, 0.f), 0.25f, std::nextafter(0.25f, 1.f)}) { + auto c = Scored(1, 0.f); c.eventStrength = strength; + auto profile = p; profile.acquireThreshold = 0.25f; profile.releaseThreshold = 0.125f; + Harness h; + CHECK(h.Run(Observer(), std::span(&c, 1), profile, 0) == StepStatus::Ok); + CHECK((h.output.selected == 1) == (strength >= 0.25f)); + CHECK(test::ConsistentStep(h, profile)); + } + for (float strength : {std::nextafter(0.375f, 0.f), 0.375f, std::nextafter(0.375f, 1.f)}) { + auto c = Scored(1, 0.f); c.eventStrength = strength; + Harness h; + const CandidateSnapshot first[]{Scored(1, 0.625f)}; + CHECK(h.Run(Observer(), first, p, 0) == StepStatus::Ok); + CHECK(h.Run(Observer(), std::span(&c, 1), p, 0) == StepStatus::Ok); + CHECK((h.output.selected == 1) == (strength >= 0.375f)); + CHECK(test::ConsistentStep(h, p)); + } +} + +void TimeTranslationAndDecayProperties() +{ + const ContextPreference contexts[1]{}; + auto p = test::TemporalProfile(contexts); + p.noveltyBeta = 0.75f; + constexpr TimeMs lo = (std::numeric_limits::min)(); + constexpr TimeMs hi = (std::numeric_limits::max)(); + const TimeMs starts[]{lo, -10000, 0, hi - 10000}; + const TimeMs offsets[]{0, 1, 499, 500, 999, 1000, 2000, 8000}; + test::Random rng(0xC180102); + for (int trial = 0; trial < 128; ++trial) { + const float strength = rng.Unit(); + const float raw = rng.Unit(); + std::array translated; + double previousNovelty = 1; + float previousScore = 1; + for (auto offset : offsets) { + for (std::size_t i = 0; i < translated.size(); ++i) { + const CandidateSnapshot incumbent[]{Scored(1, 0.625f)}; + if (offset == 0) { CHECK(translated[i].Run(Observer(), incumbent, p, starts[i]) == StepStatus::Ok); } + auto event = Scored(2, raw); + event.eventTimeMs = starts[i]; event.eventStrength = strength; + const CandidateSnapshot frame[]{incumbent[0], event}; + CHECK(translated[i].Run(Observer(), frame, p, starts[i] + offset) == StepStatus::Ok); + CHECK(test::ConsistentStep(translated[i], p)); + const auto& reference = translated[0]; + CHECK(test::SameDecision(reference.output, translated[i].output)); + CHECK(test::SamePolicyTrace(reference.trace, translated[i].trace)); + CHECK(reference.memory.current == translated[i].memory.current); + CHECK(static_cast(reference.memory.focusStartTime) - static_cast(starts[0]) == + static_cast(translated[i].memory.focusStartTime) - static_cast(starts[i])); + CHECK(reference.Find(2)->eventNovelty == translated[i].Find(2)->eventNovelty); + CHECK(reference.Find(2)->effectiveScore == translated[i].Find(2)->effectiveScore); + } + const auto& e = *translated[0].Find(2); + CHECK(e.eventNovelty <= previousNovelty && e.effectiveScore <= previousScore); + if (offset == 1000) { CHECK(e.eventNovelty == static_cast(strength) / 2); } + if (offset == 2000) { CHECK(e.eventNovelty == static_cast(strength) / 4); } + previousNovelty = e.eventNovelty; previousScore = e.effectiveScore; + } + } +} +} // namespace + +int main() +{ + test::Run("rigid geometry transforms, host distance units and observer ID", + GeometryTransformsPreserveScores); + test::Run("finite extremes and adjacent distance cutoffs", FiniteExtremesAndAdjacentCutoffs); + test::Run("adjacent saturation and effective policy thresholds", AdjacentSaturationAndThresholds); + test::Run("extreme time translations and monotonic novelty decay", TimeTranslationAndDecayProperties); + return test::Finish("interest_property_test"); +} diff --git a/tests/step_rate_test.cpp b/tests/step_rate_test.cpp new file mode 100644 index 0000000..18c2598 --- /dev/null +++ b/tests/step_rate_test.cpp @@ -0,0 +1,471 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Compares temporal selection and minimum dwell at 5 Hz and 20 Hz. +// Scenes are piecewise-constant functions of host time, changing on a 50 ms grid. +// They are sampled at 5 Hz (200 ms), 20 Hz (50 ms) and a 1 kHz reference rate that +// observes each change when it occurs. Step sees a change at its first call at or +// after it, so detection is late by less than one interval. A dwell deadline based +// on an already-late focus start can add another interval. Every step is checked +// against the policy rules through test::ConsistentStep. +// +// Unless stated otherwise, test::TemporalProfile sets acquire/release to 0.5/0.375, +// switch/override margins to 0.125/0.25 and dwell to 500 ms. These synthetic scenes +// measure timing and stability tradeoffs, not perceptual attention quality. + +#include "test_support.hpp" + +#include +#include + +namespace { + +using interest::AttentionProfile; +using interest::CandidateId; +using interest::CandidateSnapshot; +using interest::ContextPreference; +using interest::DecisionKind; +using interest::DecisionReason; +using interest::StepStatus; +using interest::TimeMs; +using test::Harness; +using test::Observer; +using test::Scored; + +using Candidates = std::vector; + +constexpr TimeMs kReference = 1; // 1 kHz +constexpr TimeMs kRates[2]{50, 200}; +const char* const kRateNames[2]{"20 Hz", "5 Hz"}; + +// One target change (Acquire, Switch or Clear). +struct Change { + TimeMs time; + CandidateId selected; + DecisionReason reason; +}; + +struct Run { + std::vector changes; + TimeMs end = 0; + int statelessChanges = 0; // target changes of B1 on the same steps + int overrides = 0; // DwellOverride decisions + bool consistent = true; +}; + +template +Run RunScene(const Scene& scene, const AttentionProfile& profile, TimeMs interval, + TimeMs duration) +{ + Run run; + run.end = duration; + Harness h; + CandidateId stateless = 0; + for (TimeMs now = 0; now < duration; now += interval) { + const Candidates candidates = scene(now); + if (h.Run(Observer(), candidates, profile, now) != StepStatus::Ok || + !test::ConsistentStep(h, profile)) { + run.consistent = false; + } + const DecisionKind kind = h.output.kind; + if (kind == DecisionKind::Acquire || kind == DecisionKind::Switch || + kind == DecisionKind::Clear) { + run.changes.push_back({now, h.output.selected, h.output.reason}); + } + run.overrides += h.output.reason == DecisionReason::DwellOverride ? 1 : 0; + const CandidateId next = test::StatelessSelection(h.trace); + run.statelessChanges += next != stateless ? 1 : 0; + stateless = next; + } + return run; +} + +// How long `id` was selected; a selection lasts until the next change. +TimeMs HeldMs(const Run& run, CandidateId id) +{ + TimeMs held = 0; + for (std::size_t i = 0; i < run.changes.size(); ++i) { + if (run.changes[i].selected == id) { + const TimeMs until = i + 1 < run.changes.size() ? run.changes[i + 1].time : run.end; + held += until - run.changes[i].time; + } + } + return held; +} + +int ChangesTo(const Run& run, CandidateId id) +{ + int count = 0; + for (const Change& change : run.changes) { + count += change.selected == id ? 1 : 0; + } + return count; +} + +// Time from `from` until the first change to `id` before `to`; -1 if there is none. +TimeMs LatencyTo(const Run& run, CandidateId id, TimeMs from, TimeMs to) +{ + for (const Change& change : run.changes) { + if (change.selected == id && change.time >= from && change.time < to) { + return change.time - from; + } + } + return -1; +} + +double PerMinute(int count, TimeMs duration) +{ + return count * 60000.0 / static_cast(duration); +} + +// Deterministic per-cell noise in [-amplitude, amplitude], one value per 50 ms cell. +std::vector CellNoise(std::uint64_t seed, TimeMs duration, float amplitude) +{ + test::Random rng(seed); + std::vector noise(static_cast(duration / 50 + 1)); + for (float& value : noise) { + value = amplitude * (2.f * rng.Unit() - 1.f); + } + return noise; +} + +float At(const std::vector& cells, TimeMs t) +{ + return cells[static_cast(t / 50)]; +} + +// --- C12 ----------------------------------------------------------------------------- + +struct TimedScene { + const char* name; + TimeMs duration; + Candidates (*scene)(TimeMs); +}; + +// C11 timing: B (+switchMargin) appears 200 ms into A's dwell and leaves at 3050 ms. +Candidates PersistentChallenger(TimeMs t) +{ + Candidates c; + if (t >= 150) { + c.push_back(Scored(1, 0.625f)); + } + if (t >= 350 && t < 3050) { + c.push_back(Scored(2, 0.75f)); + } + return c; +} + +// C11a timing: B (+overrideMargin) appears 200 ms into A's dwell. +Candidates StrongChallenger(TimeMs t) +{ + Candidates c; + if (t >= 150) { + c.push_back(Scored(1, 0.625f)); + } + if (t >= 350) { + c.push_back(Scored(2, 0.875f)); + } + return c; +} + +// C10 timing: A leaves 300 ms into its dwell; B qualifies as the fallback. +Candidates InvalidDuringDwell(TimeMs t) +{ + Candidates c; + if (t >= 150 && t < 450) { + c.push_back(Scored(1, 0.75f)); + } + if (t >= 150) { + c.push_back(Scored(2, 0.5625f)); + } + return c; +} + +// Release deferred by dwell, then reacquisition: A falls below release 200 ms after it is +// acquired, and recovers at 4050 ms. +Candidates DeferredRelease(TimeMs t) +{ + if (t < 150) { + return {}; + } + return {Scored(1, t < 350 || t >= 4050 ? 0.625f : 0.25f)}; +} + +// C09 timing: A alternates 0.5625/0.4375 every 150 ms from 150 ms, then falls to 0.25. +Candidates ThresholdOscillation(TimeMs t) +{ + if (t < 150) { + return {}; + } + if (t >= 3000) { + return {Scored(1, 0.25f)}; + } + return {Scored(1, ((t - 150) / 150) % 2 == 0 ? 0.5625f : 0.4375f)}; +} + +// The same scenes give the same sequence of target changes and reasons at 5 Hz, 20 Hz +// and 1 kHz; each change is late by less than two intervals of its rate. +void C12_SameScenesAt5And20Hz() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = test::TemporalProfile(contexts); + const TimedScene scenes[]{ + {"C11 persistent challenger in dwell", 5000, PersistentChallenger}, + {"C11a strong challenger in dwell", 3000, StrongChallenger}, + {"C10 target leaves during dwell", 3000, InvalidDuringDwell}, + {"release deferred by dwell", 6000, DeferredRelease}, + {"C09 threshold oscillation", 4000, ThresholdOscillation}, + }; + std::printf(" %-36s %-34s %s\n", "scene", "1 kHz changes (ms, id, reason)", + "delay at 20 Hz / 5 Hz (ms)"); + bool sameSequence = true; + bool withinBound = true; + bool consistent = true; + for (const TimedScene& s : scenes) { + const Run reference = RunScene(s.scene, profile, kReference, s.duration); + Run runs[2]; + for (std::size_t r = 0; r < 2; ++r) { + runs[r] = RunScene(s.scene, profile, kRates[r], s.duration); + consistent = consistent && runs[r].consistent && reference.consistent; + sameSequence = sameSequence && runs[r].changes.size() == reference.changes.size(); + } + for (std::size_t i = 0; i < reference.changes.size(); ++i) { + const Change& ideal = reference.changes[i]; + std::printf(" %-36s %5lld %2llu %-22s", i == 0 ? s.name : "", + static_cast(ideal.time), + static_cast(ideal.selected), + interest::ToString(ideal.reason)); + for (std::size_t r = 0; r < 2; ++r) { + if (i >= runs[r].changes.size()) { + continue; + } + const Change& observed = runs[r].changes[i]; + const TimeMs delay = observed.time - ideal.time; + sameSequence = sameSequence && observed.selected == ideal.selected && + observed.reason == ideal.reason; + withinBound = withinBound && delay >= 0 && delay < 2 * kRates[r]; + std::printf(" %5lld", static_cast(delay)); + } + std::printf("\n"); + } + } + CHECK(consistent); + CHECK(sameSequence); + CHECK(withinBound); +} + +// C08 noise sampled at both rates. Each source is 0.70 +- 0.04 per 50 ms cell, so the 5 Hz +// run sees every fourth cell. B1 flicker grows with the update rate; B2 does not switch. +void C12_NoiseAt5And20Hz() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = test::TemporalProfile(contexts); + constexpr TimeMs kDuration = 60000; + const std::vector a = CellNoise(0x5EED0C12u, kDuration, 0.04f); + const std::vector b = CellNoise(0x5EED0C13u, kDuration, 0.04f); + const auto scene = [&a, &b](TimeMs t) { + return Candidates{Scored(1, 0.7f + At(a, t)), Scored(2, 0.7f + At(b, t))}; + }; + for (std::size_t r = 0; r < 2; ++r) { + const Run run = RunScene(scene, profile, kRates[r], kDuration); + const auto changes = static_cast(run.changes.size()); + std::printf(" C08 noise at %-6s B1 %6.1f changes/min, B2 %4.1f\n", kRateNames[r], + PerMinute(run.statelessChanges, kDuration), PerMinute(changes, kDuration)); + CHECK(run.consistent); + CHECK(changes == 1); // the first acquisition only + CHECK(run.statelessChanges > (r == 0 ? 400 : 100)); + } +} + +// --- Minimum dwell: what does it add beyond the margin and the release band? ---------- + +struct Setting { + const char* name; + TimeMs dwell; + float switchMargin; + float overrideMargin; +}; +// Dwell from 0 to 1000 ms with the baseline margins, and the alternative a host might use +// instead of dwell: a switch margin as wide as the override margin, with no dwell. +constexpr Setting kSettings[]{ + {"dwell 0", 0, 0.125f, 0.25f}, {"dwell 250", 250, 0.125f, 0.25f}, + {"dwell 500", 500, 0.125f, 0.25f}, {"dwell 1000", 1000, 0.125f, 0.25f}, + {"margin 0.25, dwell 0", 0, 0.25f, 0.25f}, +}; +constexpr std::size_t kSettingCount = sizeof(kSettings) / sizeof(kSettings[0]); +constexpr std::size_t kWide = kSettingCount - 1; + +AttentionProfile With(const Setting& setting, std::span contexts) +{ + AttentionProfile profile = test::TemporalProfile(contexts); + profile.minDwellMs = setting.dwell; + profile.switchMargin = setting.switchMargin; + profile.overrideMargin = setting.overrideMargin; + return profile; +} + +// (a) Transient above-margin spikes on a settled target. A (0.625) has been held for 2 s. +// B (0.5) jumps to 0.8, which leads A by more than switchMargin but less than +// overrideMargin, for 100, 200, 400 and 800 ms, starting at 2, 5, 8 and 11 s. +constexpr TimeMs kSpikeStart[4]{2000, 5000, 8000, 11000}; +constexpr TimeMs kSpikeLength[4]{100, 200, 400, 800}; + +Candidates SettledSpikes(TimeMs t) +{ + float b = 0.5f; + for (std::size_t i = 0; i < 4; ++i) { + b = t >= kSpikeStart[i] && t < kSpikeStart[i] + kSpikeLength[i] ? 0.8f : b; + } + return {Scored(1, 0.625f), Scored(2, b)}; +} + +// (b) The same spike, 200 ms long, 100 ms after A is (re)acquired. Each 3 s cycle starts +// with A absent for 200 ms; B rests at 0.45, below acquire. +Candidates FreshSpikes(TimeMs t) +{ + const TimeMs phase = t % 3000; + Candidates c; + if (phase >= 200) { + c.push_back(Scored(1, 0.625f)); + } + c.push_back(Scored(2, phase >= 300 && phase < 500 ? 0.8f : 0.45f)); + return c; +} + +// (c) A sustained challenger B (0.8) arrives 100, 300, 600 or 1200 ms after A is acquired +// and stays until the 4 s cycle ends; each cycle starts with both absent for 200 ms. +constexpr TimeMs kOnsetAfterAcquire[4]{100, 300, 600, 1200}; + +Candidates SustainedChallengers(TimeMs t) +{ + const TimeMs phase = t % 4000; + const auto cycle = static_cast(t / 4000); + Candidates c; + if (phase >= 200) { + c.push_back(Scored(1, 0.625f)); + if (phase >= 200 + kOnsetAfterAcquire[cycle]) { + c.push_back(Scored(2, 0.8f)); + } + } + return c; +} + +void DwellAgainstTransientAndSustainedChallengers() +{ + const ContextPreference contexts[1]{}; + std::printf(" %-22s %-6s | %-17s | %-17s | %-27s\n", "setting", "rate", + "(a) settled spikes", "(b) fresh spikes", "(c) sustained: latency ms"); + std::printf(" %-22s %-6s | %8s %8s | %8s %8s | %6s %6s %6s %6s\n", "", "", "to B", + "B held", "to B", "B held", "+100", "+300", "+600", "+1200"); + TimeMs settledHeldNoDwell[2]{}; + bool consistent = true; + for (std::size_t s = 0; s < kSettingCount; ++s) { + const AttentionProfile profile = With(kSettings[s], contexts); + for (std::size_t r = 0; r < 2; ++r) { + const TimeMs interval = kRates[r]; + const Run settled = RunScene(SettledSpikes, profile, interval, 14000); + const Run fresh = RunScene(FreshSpikes, profile, interval, 12000); + const Run sustained = RunScene(SustainedChallengers, profile, interval, 16000); + consistent = consistent && settled.consistent && fresh.consistent && + sustained.consistent; + std::printf(" %-22s %-6s | %8d %8lld | %8d %8lld |", kSettings[s].name, + kRateNames[r], ChangesTo(settled, 2), + static_cast(HeldMs(settled, 2)), ChangesTo(fresh, 2), + static_cast(HeldMs(fresh, 2))); + for (std::size_t k = 0; k < 4; ++k) { + const TimeMs onset = static_cast(k) * 4000 + 200 + kOnsetAfterAcquire[k]; + const TimeMs latency = + LatencyTo(sustained, 2, onset, static_cast(k + 1) * 4000); + std::printf(" %6lld", static_cast(latency)); + + // Dwell delays a sustained challenger to the end of dwell, measured from the + // acquisition; quantization adds less than two intervals. The wide margin + // never lets a +0.175 challenger in. + const TimeMs dwellLeft = kSettings[s].dwell > kOnsetAfterAcquire[k] + ? kSettings[s].dwell - kOnsetAfterAcquire[k] + : 0; + if (s == kWide) { + CHECK(latency == -1); + } else { + CHECK(latency >= 0 && latency < dwellLeft + 2 * interval); + CHECK(latency + interval > dwellLeft); + } + } + std::printf("\n"); + + if (s == kWide) { + CHECK(ChangesTo(settled, 2) == 0 && ChangesTo(fresh, 2) == 0); + continue; + } + // A settled target has no dwell left, so dwell never prevents the switch to a + // spike; it only keeps B selected until B's own dwell ends. + CHECK(ChangesTo(settled, 2) == 4); + if (kSettings[s].dwell == 0) { + settledHeldNoDwell[r] = HeldMs(settled, 2); + } else { + CHECK(HeldMs(settled, 2) > settledHeldNoDwell[r]); + } + // Right after an acquisition (cycle phase 200), dwell absorbs a spike (phase 300 + // to 500) unless a step falls inside the spike once dwell has ended. + const TimeMs from = kSettings[s].dwell + 200 > 300 ? kSettings[s].dwell + 200 : 300; + const TimeMs firstStep = (from + interval - 1) / interval * interval; + CHECK(ChangesTo(fresh, 2) == (firstStep < 500 ? 4 : 0)); + } + } + CHECK(consistent); +} + +// (d) Score noise larger than the switch margin: A and B are both 0.6 +- 0.15 per 50 ms +// cell, so their difference reaches +-0.3. In the second scene B's mean is 0.15 higher; +// "on B" is the share of time spent on that better source despite the noise. +void DwellAgainstNoiseLargerThanMargin() +{ + const ContextPreference contexts[1]{}; + constexpr TimeMs kDuration = 60000; + const std::vector a = CellNoise(0x5EED0D01u, kDuration, 0.15f); + const std::vector b = CellNoise(0x5EED0D02u, kDuration, 0.15f); + const auto equal = [&a, &b](TimeMs t) { + return Candidates{Scored(1, 0.6f + At(a, t)), Scored(2, 0.6f + At(b, t))}; + }; + const auto betterB = [&a, &b](TimeMs t) { + return Candidates{Scored(1, 0.6f + At(a, t)), Scored(2, 0.75f + At(b, t))}; + }; + std::printf(" %-22s %-6s | %14s %10s | %14s %10s\n", "setting", "rate", + "equal: chg/min", "overrides", "B+0.15: chg/min", "on B %"); + double changesPerMinute[kSettingCount][2]{}; + bool consistent = true; + for (std::size_t s = 0; s < kSettingCount; ++s) { + const AttentionProfile profile = With(kSettings[s], contexts); + for (std::size_t r = 0; r < 2; ++r) { + const Run same = RunScene(equal, profile, kRates[r], kDuration); + const Run better = RunScene(betterB, profile, kRates[r], kDuration); + consistent = consistent && same.consistent && better.consistent; + changesPerMinute[s][r] = + PerMinute(static_cast(same.changes.size()), kDuration); + const double onB = 100.0 * static_cast(HeldMs(better, 2)) / kDuration; + std::printf(" %-22s %-6s | %14.1f %10d | %14.1f %10.1f\n", kSettings[s].name, + kRateNames[r], changesPerMinute[s][r], same.overrides, + PerMinute(static_cast(better.changes.size()), kDuration), onB); + CHECK(onB > 80.0); // every setting mostly stays on the better source + } + } + CHECK(consistent); + for (std::size_t r = 0; r < 2; ++r) { + // Each longer dwell switches less often on equal noise. + CHECK(changesPerMinute[1][r] < changesPerMinute[0][r]); + CHECK(changesPerMinute[2][r] < changesPerMinute[1][r]); + CHECK(changesPerMinute[3][r] < changesPerMinute[2][r]); + } +} + +} // namespace + +int main() +{ + test::Run("C12 same scenes at 5 Hz and 20 Hz", C12_SameScenesAt5And20Hz); + test::Run("C12 noise at 5 Hz and 20 Hz", C12_NoiseAt5And20Hz); + test::Run("dwell against transient and sustained challengers", + DwellAgainstTransientAndSustainedChallengers); + test::Run("dwell against noise larger than the margin", DwellAgainstNoiseLargerThanMargin); + return test::Finish("interest_rate_test"); +} diff --git a/tests/step_retention_test.cpp b/tests/step_retention_test.cpp new file mode 100644 index 0000000..0011a6c --- /dev/null +++ b/tests/step_retention_test.cpp @@ -0,0 +1,325 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Regression evidence for event novelty, geometric limits and configuration sensitivity. +#include "test_support.hpp" + +#include +#include + +namespace { +using interest::AttentionProfile; +using interest::CandidateSnapshot; +using interest::ContextPreference; +using interest::DecisionKind; +using interest::TimeMs; +using test::Harness; + +enum class Control { B2, Novelty, Salience, Salience8 }; +constexpr Control controls[]{Control::B2, Control::Novelty, Control::Salience, Control::Salience8}; +constexpr Control crowdControls[]{Control::B2, Control::Novelty}; + +const char* Name(Control c) +{ + switch (c) { + case Control::B2: return "B2"; + case Control::Novelty: return "novelty"; + case Control::Salience: return "salience"; + case Control::Salience8: return "salience8"; + } + return "unknown"; +} + +AttentionProfile Profile(std::span contexts, Control control) +{ + auto p = test::TemporalProfile(contexts); + p.minDwellMs = 0; + p.noveltyBeta = control == Control::Novelty ? 0.75f : 0.f; + return p; +} + +double Novelty(const CandidateSnapshot& c, TimeMs now, TimeMs halfLife) +{ + if (c.eventStrength == 0.f) { return 0; } + const auto age = static_cast(now) - static_cast(c.eventTimeMs); + return static_cast(c.eventStrength) * + std::exp2(-static_cast(age) / static_cast(halfLife)); +} + +// The strongest unit-strength host pulse reaches salience=1 at onset. It does not +// modify attraction, affinity, geometry or the temporal policy. Other controls see the +// exact base snapshots, including the same event pair. +void HostPulse(std::vector& frame, Control c, TimeMs now, TimeMs halfLife) +{ + if (c != Control::Salience && c != Control::Salience8) { return; } + for (auto& candidate : frame) { + candidate.salience = static_cast(static_cast(candidate.salience) + + (1.0 - static_cast(candidate.salience)) * Novelty(candidate, now, halfLife)); + candidate.eventStrength = 0.f; + } +} + +struct CrowdMetrics { + int switches = 0; + int clears = 0; + std::size_t peak = 0; + std::size_t evictions = 0; + std::size_t decayUpdates = 0; +}; + +CrowdMetrics Crowd(std::size_t population, std::uint64_t seed, bool varied, + const AttentionProfile& p) +{ + test::Random rng(seed); + std::vector frame; + for (std::size_t i = 0; i < population; ++i) { + const float score = varied ? 0.7f + 0.15f * rng.Unit() : 0.8f; + frame.push_back(test::Scored(1 + i, score)); + } + Harness h; + CrowdMetrics m; + for (TimeMs now = 0; now <= 60000; now += 50) { + CHECK(h.Run(test::Observer(), frame, p, now) == interest::StepStatus::Ok); + CHECK(test::ConsistentStep(h, p)); + m.switches += h.output.kind == DecisionKind::Switch ? 1 : 0; + m.clears += h.output.kind == DecisionKind::Clear ? 1 : 0; + rng.Shuffle(frame); + } + return m; +} + +void CapacitySweep() +{ + const ContextPreference contexts[1]{}; + for (bool varied : {false, true}) { + for (std::size_t population : {8u, 16u, 17u, 24u, 40u}) { + for (Control control : crowdControls) { + const auto p = Profile(contexts, control); + const auto m = Crowd(population, 0xC1700, varied, p); + if (control == Control::B2 || control == Control::Novelty) { + CHECK(m.switches == 0 && m.clears == 0); + } + CHECK(m.peak == 0 && m.evictions == 0 && m.decayUpdates == 0); + std::printf(" crowd n=%zu varied=%d %-8s switch=%d clear=%d\n", + population, varied, Name(control), m.switches, m.clears); + } + } + } +} + +void C13C15AndRecovery() +{ + const ContextPreference contexts[1]{}; + for (Control control : crowdControls) { + const auto p = Profile(contexts, control); + Harness h; + int renewals = 0; + int alreadyAttending = 0; + TimeMs firstClear = -1; + for (TimeMs now = 0; now <= 90000; now += 50) { + std::vector frame; + if (now < 60000 || now >= 80000) { + auto c = test::Scored(1, 0.8f); + if (now >= 10150 && now < 60000) { + c.eventTimeMs = now < 20150 ? 10150 : 20150; + c.eventStrength = 1; + } + frame.push_back(c); + } + CHECK(h.Run(test::Observer(), frame, p, now) == interest::StepStatus::Ok); + if (now < 60000 && firstClear < 0 && h.output.kind == DecisionKind::Clear) { + firstClear = now; + } + if (now == 10150 || now == 20150) { + renewals += h.output.kind == DecisionKind::Acquire ? 1 : 0; + alreadyAttending += h.output.kind == DecisionKind::Keep ? 1 : 0; + } + if (now == 80000) { CHECK(h.output.kind == DecisionKind::Acquire); } + } + if (control == Control::B2 || control == Control::Novelty) { + CHECK(firstClear == -1 && alreadyAttending == 2 && renewals == 0); + } + std::printf(" C13/C15 %-8s firstClear=%lldms renewals=%d alreadyAttending=%d " + "return=80000ms\n", Name(control), static_cast(firstClear), + renewals, alreadyAttending); + } +} + +struct EventMetrics { + int frontCaptures = 0; + int behindCaptures = 0; + int returns = 0; + int falseSwitches = 0; + int changes = 0; + TimeMs maxLatency = -1; + TimeMs duration = 0; +}; + +EventMetrics EventStream(Control control, TimeMs interval, bool weak) +{ + const ContextPreference contexts[1]{}; + auto p = Profile(contexts, control); + p.salienceWeight = control == Control::Salience8 ? 8.f : 1.f; + // Attraction/salience tradeoff is held fixed for all other controls. Affinity + // weight stays zero, so the pulse cannot borrow a third consideration's budget. + Harness h, shuffled, untraced; + test::Random rng(0xC130017); + EventMetrics m; + int capturedCycle = -1; + int returnedCycle = -1; + for (TimeMs now = 0; now < 600000; now += interval) { // ten minutes, 120 independent events + const int cycle = static_cast(now / 5000); + const int label = cycle % 3; // front important, far irrelevant, behind important + const TimeMs onset = static_cast(cycle) * 5000 + 150; + std::vector frame; + for (interest::CandidateId id = 1; id <= 24; ++id) { + const float score = id == 1 ? 0.625f : 0.45f + rng.Unit() * 0.1f; + frame.push_back(test::NonSpatial(id, score, score)); + } + const float cue = weak ? 0.1f : 0.6f; + auto event = test::NonSpatial(99, cue, cue); + if (label == 1) { event.spatial = interest::SpatialKind::Point; event.position = {19, 0, 0}; } + if (label == 2) { event.spatial = interest::SpatialKind::Bearing; event.bearing = {-1.f, 0, 0}; } + if (now >= onset) { event.eventTimeMs = onset; event.eventStrength = 1; } + frame.push_back(event); + // A non-event salient cue, labelled irrelevant. Increasing the salience + // weight must account for this cost on exactly the same underlying stream. + const bool spike = now % 5000 >= 3000 && now % 5000 < 3250; + frame.push_back(test::NonSpatial(100, 0.f, spike ? 1.f : 0.f)); + HostPulse(frame, control, now, p.noveltyHalfLifeMs); + CHECK(h.Run(test::Observer(), frame, p, now) == interest::StepStatus::Ok); + CHECK(test::ConsistentStep(h, p)); + m.changes += h.output.selected != h.output.previous ? 1 : 0; + if (h.output.kind == DecisionKind::Switch && + (h.output.selected != 1 && (h.output.selected != 99 || label == 1))) { + ++m.falseSwitches; + } + if (h.output.selected == 99) { + m.duration += interval; + if (capturedCycle != cycle && now >= onset && now - onset <= 1000) { + capturedCycle = cycle; + if (label == 0) { ++m.frontCaptures; } + if (label == 2) { ++m.behindCaptures; } + m.maxLatency = (std::max)(m.maxLatency, now - onset); + } + } + if (capturedCycle == cycle && returnedCycle != cycle && h.output.selected == 1) { + returnedCycle = cycle; + ++m.returns; + } + interest::Reserve(untraced.scratch, frame.size()); + CHECK(interest::Step(test::Observer(), frame, p, now, untraced.memory, untraced.scratch, + untraced.output) == interest::StepStatus::Ok); + rng.Shuffle(frame); + CHECK(shuffled.Run(test::Observer(), frame, p, now) == interest::StepStatus::Ok); + CHECK(test::SameDecision(h.output, shuffled.output) && test::SameMemory(h.memory, shuffled.memory)); + CHECK(test::SameDecision(h.output, untraced.output) && test::SameMemory(h.memory, untraced.memory)); + CHECK(test::SamePolicyTrace(h.trace, shuffled.trace)); + for (const auto& e : shuffled.trace.candidates) { + CHECK(test::SameEntry(e, *h.Find(e.id))); + } + } + std::printf(" events %-8s %lldHz weak=%d front=%d/40 behind=%d/40 return=%d " + "falseSwitch=%d changes=%d maxLatency=%lldms attention=%lldms\n", Name(control), + static_cast(1000 / interval), weak, m.frontCaptures, m.behindCaptures, + m.returns, m.falseSwitches, m.changes, static_cast(m.maxLatency), + static_cast(m.duration)); + return m; +} + +void NoveltyVersusHostSalience() +{ + for (Control control : controls) { + const auto m = EventStream(control, 50, true); + if (control == Control::Novelty) { + CHECK(m.frontCaptures == 40 && m.behindCaptures == 0 && m.falseSwitches == 0); + CHECK(m.returns == 40 && m.maxLatency == 0); + } + if (control == Control::B2 || control == Control::Salience) { CHECK(m.frontCaptures == 0); } + if (control == Control::Salience8) { CHECK(m.frontCaptures == 40 && m.falseSwitches >= 120); } + } + const auto low = EventStream(Control::Novelty, 200, true); + CHECK(low.frontCaptures == 40 && low.maxLatency == 50 && low.falseSwitches == 0); + // Easy event cues can be served by host salience alone: acknowledge this simpler domain. + const auto easy = EventStream(Control::Salience, 50, false); + const auto easyNovelty = EventStream(Control::Novelty, 50, false); + CHECK(easy.frontCaptures == 40 && easy.falseSwitches == 0); + CHECK(easy.frontCaptures == easyNovelty.frontCaptures && easy.returns == easyNovelty.returns && + easy.duration == easyNovelty.duration && easy.changes == easyNovelty.changes); +} + +void C14C16AndSaturation() +{ + ContextPreference contexts[2]{}; + contexts[0].affinityTags = contexts[1].affinityTags = 3; + contexts[0].tagAffinity[0] = contexts[1].tagAffinity[1] = 0.9f; + for (Control control : crowdControls) { + auto p = Profile(contexts, control); + p.salienceWeight = p.affinityWeight = 1; + CandidateSnapshot frame[]{test::NonSpatial(1, 0.4f, 0.4f), test::NonSpatial(2, 0.4f, 0.4f)}; + frame[0].tags = 1; frame[1].tags = 2; + Harness a, b; + CHECK(a.Run(test::Observer(0), frame, p, 0) == interest::StepStatus::Ok); + CHECK(b.Run(test::Observer(1), frame, p, 0) == interest::StepStatus::Ok); + CHECK(a.output.selected == 1 && b.output.selected == 2); + } + auto p = Profile(contexts, Control::Novelty); + const CandidateSnapshot event[]{[] { auto c = test::Scored(1, 0.25f); + c.eventStrength = 1; c.eventTimeMs = -1000; return c; }()}; + Harness h; + for (TimeMs now = -1000; now <= 4000; now += 50) { + CHECK(h.Run(test::Observer(), event, p, now) == interest::StepStatus::Ok); + CHECK(h.Find(1)->eventNovelty == Novelty(event[0], now, p.noveltyHalfLifeMs)); + } + CandidateSnapshot pair[]{test::Scored(1, 0.8f), test::Scored(2, 0.9f)}; + pair[0].eventStrength = 0.5f; pair[1].eventStrength = 1; + Harness tie; + CHECK(tie.Run(test::Observer(), std::span(pair, 1), p, 0) == interest::StepStatus::Ok); + CHECK(tie.Run(test::Observer(), pair, p, 0) == interest::StepStatus::Ok); + CHECK(tie.Find(1)->effectiveScore == 1 && tie.Find(2)->effectiveScore == 1); + CHECK_DECISION(tie, Keep, HighestScore, 1); // saturation hides a >margin unsaturated lead + CHECK(static_cast(pair[1].attraction) + 0.75 - + (static_cast(pair[0].attraction) + 0.375) > static_cast(p.switchMargin)); + std::printf(" C16 contexts choose different IDs; C14 exact age; saturation masks " + "a passing unsaturated lead as a temporal-policy tie\n"); +} + +void NoveltySensitivity() +{ + const ContextPreference contexts[1]{}; + for (float beta : {0.25f, 0.5f, 0.75f, 1.f}) { + for (TimeMs halfLife : {TimeMs{250}, TimeMs{1000}, TimeMs{4000}}) { + auto p = Profile(contexts, Control::Novelty); + p.noveltyBeta = beta; + p.noveltyHalfLifeMs = halfLife; + Harness h; + TimeMs captured = -1; + TimeMs returned = -1; + for (TimeMs now = 0; now <= 15000; now += 50) { + CandidateSnapshot frame[]{test::Scored(1, 0.625f), test::Scored(2, 0.1f)}; + if (now >= 150) { frame[1].eventTimeMs = 150; frame[1].eventStrength = 1; } + CHECK(h.Run(test::Observer(), frame, p, now) == interest::StepStatus::Ok); + if (captured < 0 && h.output.selected == 2) { captured = now; } + if (captured >= 0 && returned < 0 && h.output.selected == 1) { returned = now; } + } + CHECK((captured >= 0) == (beta >= 0.75f)); + CHECK(captured < 0 || (captured == 150 && returned > captured)); + std::printf(" novelty sensitivity beta=%.2f halfLife=%lldms capture=%lldms " + "duration=%lldms\n", static_cast(beta), static_cast(halfLife), + static_cast(captured), static_cast(captured < 0 ? 0 : returned - captured)); + } + } +} +} // namespace + +int main() +{ + std::printf(" footprints observer=%zu candidate=%zu trace=%zu bytes\n", + sizeof(interest::ObserverMemory), sizeof(CandidateSnapshot), sizeof(interest::CandidateTrace)); + test::Run("C17 stable selection across population sizes", CapacitySweep); + test::Run("C13/C15 benefit labels and recovery", C13C15AndRecovery); + test::Run("ten-minute event streams versus host salience", NoveltyVersusHostSalience); + test::Run("C14/C16 and saturation cost", C14C16AndSaturation); + test::Run("novelty beta and half-life sensitivity", NoveltySensitivity); + return test::Finish("interest_retention_test"); +} diff --git a/tests/step_scenario_test.cpp b/tests/step_scenario_test.cpp new file mode 100644 index 0000000..0c83776 --- /dev/null +++ b/tests/step_scenario_test.cpp @@ -0,0 +1,787 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Core snapshot, eligibility, decision and validation scenarios. + +#include "test_support.hpp" + +#include +#include +#include +#include + +namespace { + +using interest::AttentionProfile; +using interest::CandidateSnapshot; +using interest::ContextPreference; +using interest::Decision; +using interest::DecisionKind; +using interest::DecisionReason; +using interest::DecisionTrace; +using interest::Eligibility; +using interest::ObserverMemory; +using interest::ObserverSnapshot; +using interest::SpatialKind; +using interest::StepStatus; +using interest::TagMask; +using test::Harness; +using test::NonSpatial; +using test::Observer; +using test::PointAt; +using test::Profile; + +constexpr float kNaN = std::numeric_limits::quiet_NaN(); +constexpr float kInf = std::numeric_limits::infinity(); +constexpr double kNaND = std::numeric_limits::quiet_NaN(); +constexpr double kInfD = std::numeric_limits::infinity(); + +constexpr TagMask Tag(unsigned bit) +{ + return TagMask{1} << bit; +} + +Eligibility EligibilityOf(const Harness& h, interest::CandidateId id) +{ + const interest::CandidateTrace* entry = h.Find(id); + return entry != nullptr ? entry->eligibility : Eligibility::Eligible; +} + +// Values a failed step must leave untouched. +void FillSentinels(Harness& h) +{ + h.output = Decision{DecisionKind::Switch, DecisionReason::HighestScore, 77, 66, 0.25f}; + h.trace.candidates.assign(1, interest::CandidateTrace{}); + h.trace.candidates[0].id = 99; + h.trace.leading = 99; + h.trace.leadingScore = 0.75f; + h.trace.challenger = 98; + h.trace.dwellRemainingMs = 1234; + h.trace.acquireThreshold = 0.5f; + h.trace.overrideMargin = 0.125f; +} + +bool SentinelsIntact(const Harness& h) +{ + const Decision expected{DecisionKind::Switch, DecisionReason::HighestScore, 77, 66, 0.25f}; + return test::SameDecision(h.output, expected) && h.trace.candidates.size() == 1 && + h.trace.candidates[0].id == 99 && h.trace.leading == 99 && + h.trace.leadingScore == 0.75f && h.trace.challenger == 98 && + h.trace.dwellRemainingMs == 1234 && h.trace.acquireThreshold == 0.5f && + h.trace.overrideMargin == 0.125f; +} + +void C01_EmptyCandidateList() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); + Harness h; + + CHECK(h.Run(Observer(), {}, profile, 0) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::None); + CHECK(h.output.reason == DecisionReason::NoEligibleCandidate); + CHECK(h.output.selected == 0 && h.output.previous == 0 && h.output.rawScore == 0.f); + CHECK(h.trace.candidates.empty() && h.trace.leading == 0); + CHECK(h.memory.current == 0 && h.memory.hasStepped && h.memory.lastStepTime == 0); + + // With a previous selection, an empty list clears it. The reason names the + // absent current target rather than the empty list. + const CandidateSnapshot one[]{PointAt(7, 5, 0, 1.f, 1.f)}; + CHECK(h.Run(Observer(), one, profile, 10) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Acquire && h.output.selected == 7); + CHECK(h.Run(Observer(), {}, profile, 20) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Clear); + CHECK(h.output.reason == DecisionReason::CurrentInvalid); + CHECK(h.output.previous == 7 && h.output.selected == 0); + CHECK(h.memory.current == 0); +} + +void C02_InvalidDisabledAndOutOfRangeCandidates() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); + + // Every rejected candidate would outscore the valid one (id 100) if it were eligible. + const auto strong = [](interest::CandidateId id, auto change) { + CandidateSnapshot candidate = PointAt(id, 5, 0, 1.f, 1.f); + change(candidate); + return candidate; + }; + using Edit = CandidateSnapshot&; + std::vector candidates{ + strong(0, [](Edit) {}), + strong(1, [](Edit c) { c.attraction = kNaN; }), + strong(2, [](Edit c) { c.salience = 1.5f; }), + strong(3, [](Edit c) { c.perceptionStrength = -0.1f; }), + strong(4, [](Edit c) { c.perceptionStrength = kInf; }), + strong(5, [](Edit c) { c.maxDistance = kNaN; }), + strong(6, [](Edit c) { c.maxDistance = -1.f; }), + strong(7, [](Edit c) { c.spatial = static_cast(7); }), + strong(8, [](Edit c) { c.position.x = kInfD; }), + strong(9, [](Edit c) { c.position.y = kNaND; }), + strong(10, [](Edit c) { c.spatial = SpatialKind::Bearing; c.bearing = {0.f, 0.f, 0.f}; }), + strong(11, [](Edit c) { c.spatial = SpatialKind::Bearing; c.bearing = {kNaN, 1.f, 0.f}; }), + strong(12, [](Edit c) { c.enabled = false; }), + strong(13, [](Edit c) { c.perceptionStrength = 0.f; }), + strong(14, [](Edit c) { c.position.x = 20.5; }), + strong(15, [](Edit c) { c.position = {1e308, 1e308, 0.0}; }), // distance overflows + strong(16, [](Edit c) { c.maxDistance = 4.f; }), // own cutoff < 5 + strong(17, [](Edit c) { c.enabled = false; c.attraction = kNaN; }), + }; + candidates.push_back(PointAt(100, 5, 0, 0.5f, 0.5f)); + + Harness h; + CHECK(h.Run(Observer(), candidates, profile, 0) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Acquire && h.output.selected == 100); + CHECK(h.trace.candidates.size() == candidates.size()); + CHECK(h.trace.candidates[0].eligibility == Eligibility::InvalidId); + for (interest::CandidateId id = 1; id <= 7; ++id) { + CHECK(EligibilityOf(h, id) == Eligibility::InvalidValue); + } + for (interest::CandidateId id = 8; id <= 11; ++id) { + CHECK(EligibilityOf(h, id) == Eligibility::InvalidGeometry); + } + CHECK(EligibilityOf(h, 12) == Eligibility::Disabled); + CHECK(EligibilityOf(h, 13) == Eligibility::NotPerceived); + CHECK(EligibilityOf(h, 14) == Eligibility::OutOfRange); + CHECK(EligibilityOf(h, 15) == Eligibility::OutOfRange); + CHECK(EligibilityOf(h, 16) == Eligibility::OutOfRange); + CHECK(EligibilityOf(h, 17) == Eligibility::InvalidValue); // invalid input outranks Disabled + CHECK(h.Find(14)->distance > 20.0 && std::isinf(h.Find(15)->distance)); + CHECK(h.Find(1)->rawScore == 0.f && h.Find(14)->rawScore == 0.f); + + // Without the valid candidate there is no false target. + candidates.pop_back(); + Harness none; + CHECK(none.Run(Observer(), candidates, profile, 0) == StepStatus::Ok); + CHECK(none.output.kind == DecisionKind::None); + CHECK(none.output.reason == DecisionReason::NoEligibleCandidate); + CHECK(none.trace.leading == 0); + + // Fields unused by a spatial kind are not validated. + CandidateSnapshot pointWithBadBearing = PointAt(1, 5, 0, 0.5f, 0.5f); + pointWithBadBearing.bearing = {kNaN, kNaN, kNaN}; + CandidateSnapshot nonSpatialWithBadPosition = NonSpatial(2, 0.5f, 0.5f); + nonSpatialWithBadPosition.position.x = kNaND; + nonSpatialWithBadPosition.maxDistance = kNaN; + const CandidateSnapshot unusedFields[]{pointWithBadBearing, nonSpatialWithBadPosition}; + Harness unused; + CHECK(unused.Run(Observer(), unusedFields, profile, 0) == StepStatus::Ok); + CHECK(EligibilityOf(unused, 1) == Eligibility::Eligible); + CHECK(EligibilityOf(unused, 2) == Eligibility::Eligible); + + // Required and excluded context tags. + ContextPreference tagged{}; + tagged.requiredTags = Tag(0); + tagged.excludedTags = Tag(1); + const ContextPreference taggedContexts[1]{tagged}; + const AttentionProfile tagProfile = Profile(taggedContexts); + CandidateSnapshot untagged = PointAt(1, 5, 0, 1.f, 1.f); + CandidateSnapshot excluded = PointAt(2, 5, 0, 1.f, 1.f); + excluded.tags = Tag(0) | Tag(1); + CandidateSnapshot allowed = PointAt(3, 5, 0, 0.5f, 0.5f); + allowed.tags = Tag(0) | Tag(5); + const CandidateSnapshot tagCandidates[]{untagged, excluded, allowed}; + Harness tags; + CHECK(tags.Run(Observer(), tagCandidates, tagProfile, 0) == StepStatus::Ok); + CHECK(EligibilityOf(tags, 1) == Eligibility::MissingRequiredTag); + CHECK(EligibilityOf(tags, 2) == Eligibility::ExcludedTag); + CHECK(tags.output.selected == 3); +} + +void C03_AcquisitionAndNoTarget() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); + const CandidateSnapshot winner = PointAt(1, 5, 0, 0.9f, 0.9f); // (0.9+0.9+0.5)/3 + const CandidateSnapshot weak = PointAt(2, 5, 0, 0.f, 0.f); // 0.5/3 < 0.3 + + const CandidateSnapshot both[]{weak, winner}; + Harness h; + CHECK(h.Run(Observer(), both, profile, 0) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Acquire); + CHECK(h.output.reason == DecisionReason::HighestScore); + CHECK(h.output.selected == 1 && h.output.previous == 0); + CHECK(test::Near(h.output.rawScore, 2.3 / 3.0, 1e-6)); + CHECK(h.output.rawScore == h.Find(1)->rawScore); + CHECK(h.trace.leading == 1 && h.trace.leadingScore == h.output.rawScore); + CHECK(h.trace.acquireThreshold == 0.3f); + + const CandidateSnapshot onlyWeak[]{weak}; + Harness below; + CHECK(below.Run(Observer(), onlyWeak, profile, 0) == StepStatus::Ok); + CHECK(below.output.kind == DecisionKind::None); + CHECK(below.output.reason == DecisionReason::BelowAcquireThreshold); + CHECK(below.output.selected == 0 && below.output.rawScore == 0.f); + CHECK(below.trace.leading == 2); + CHECK(test::Near(below.trace.leadingScore, 0.5 / 3.0, 1e-6)); + CHECK(below.memory.current == 0); + + // The threshold is inclusive. Attraction-only weights and a NonSpatial candidate + // make the raw score exactly the attraction value. + AttentionProfile exact = Profile(contexts); + exact.salienceWeight = 0.f; + exact.affinityWeight = 0.f; + exact.angleFloor = 0.f; + const CandidateSnapshot atThreshold[]{NonSpatial(5, exact.acquireThreshold, 0.f)}; + Harness at; + CHECK(at.Run(Observer(), atThreshold, exact, 0) == StepStatus::Ok); + CHECK(at.trace.leadingScore == exact.acquireThreshold); + CHECK(at.output.kind == DecisionKind::Acquire && at.output.selected == 5); + + const CandidateSnapshot justBelow[]{ + NonSpatial(5, std::nextafter(exact.acquireThreshold, 0.f), 0.f)}; + Harness under; + CHECK(under.Run(Observer(), justBelow, exact, 0) == StepStatus::Ok); + CHECK(under.output.kind == DecisionKind::None); + CHECK(under.output.reason == DecisionReason::BelowAcquireThreshold); +} + +void C04_PermutationsAndExactTies() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); + + CandidateSnapshot bearing = NonSpatial(6, 0.9f, 0.9f); + bearing.spatial = SpatialKind::Bearing; + bearing.bearing = {0.f, 1.f, 0.f}; + CandidateSnapshot disabled = NonSpatial(3, 1.f, 1.f); + disabled.enabled = false; + // Ids 9 and 4 tie exactly at the top score (identical inputs, identical path). + std::vector candidates{ + NonSpatial(9, 0.8f, 0.8f), NonSpatial(4, 0.8f, 0.8f), PointAt(2, 5, 3, 0.7f, 0.7f), + bearing, PointAt(11, 15, -2, 1.f, 1.f), disabled}; + + Harness reference; + CHECK(reference.Run(Observer(), candidates, profile, 0) == StepStatus::Ok); + CHECK(reference.output.selected == 4); + CHECK(reference.Find(9)->rawScore == reference.Find(4)->rawScore); + + std::vector order(candidates.size()); + for (std::size_t i = 0; i < order.size(); ++i) { + order[i] = i; + } + int permutations = 0; + bool allSame = true; + do { + std::vector permuted; + for (std::size_t from : order) { + permuted.push_back(candidates[from]); + } + Harness h; + if (h.Run(Observer(), permuted, profile, 0) != StepStatus::Ok || + !test::SameDecision(h.output, reference.output) || + !test::SameMemory(h.memory, reference.memory) || + h.trace.leading != reference.trace.leading) { + allSame = false; + } + for (std::size_t i = 0; i < permuted.size(); ++i) { + const interest::CandidateTrace& entry = h.trace.candidates[i]; + if (entry.id != permuted[i].id || + !test::SameEntry(entry, *reference.Find(entry.id))) { + allSame = false; + } + } + ++permutations; + } while (std::next_permutation(order.begin(), order.end())); + CHECK(permutations == 720); + CHECK(allSame); +} + +void C05_StaticAndLiveUpdatedSnapshots() +{ + ContextPreference neutral{}; + ContextPreference likesB{}; + likesB.affinityTags = Tag(1); + likesB.tagAffinity[1] = 1.f; + likesB.fallbackAffinity = 0.f; + const ContextPreference contexts[2]{neutral, likesB}; + const AttentionProfile profile = Profile(contexts); + + CandidateSnapshot a = PointAt(1, 5, 0, 0.6f, 0.2f); + a.tags = Tag(0); + CandidateSnapshot b = PointAt(2, 5, 1, 0.6f, 0.2f); // slightly off-axis + b.tags = Tag(1); + Harness h; + + CandidateSnapshot step1[]{a, b}; + CHECK(h.Run(Observer(0), step1, profile, 0) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Acquire && h.output.selected == 1); + + b.salience = 1.f; // host submits B's new salience in the next snapshot + CandidateSnapshot step2[]{a, b}; + CHECK(h.Run(Observer(0), step2, profile, 100) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Switch); + CHECK(h.output.selected == 2 && h.output.previous == 1); + + b.salience = 0.2f; + CandidateSnapshot step3[]{a, b}; + CHECK(h.Run(Observer(0), step3, profile, 200) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Switch && h.output.selected == 1); + + // Same candidates, the observer's context changes. + CHECK(h.Run(Observer(1), step3, profile, 300) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Switch && h.output.selected == 2); + CHECK(h.Find(1)->affinity == 0.f && h.Find(2)->affinity == 1.f); + + CHECK(h.Run(Observer(0), step3, profile, 400) == StepStatus::Ok); + CHECK(h.output.selected == 1); + + // A candidate omitted from the snapshot is no longer eligible. + CandidateSnapshot step6[]{b}; + CHECK(h.Run(Observer(0), step6, profile, 500) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Switch && h.output.selected == 2); +} + +void C06_IdenticalInputsStateAndTime() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); + const CandidateSnapshot candidates[]{PointAt(1, 5, 0, 0.7f, 0.4f), + PointAt(2, 9, 6, 0.9f, 0.9f), + NonSpatial(3, 0.2f, 0.3f)}; + Harness h; + CHECK(h.Run(Observer(), candidates, profile, 100) == StepStatus::Ok); + + Harness first = h; + Harness second = h; + CHECK(first.Run(Observer(), candidates, profile, 100) == StepStatus::Ok); + CHECK(second.Run(Observer(), candidates, profile, 100) == StepStatus::Ok); + CHECK(test::SameDecision(first.output, second.output)); + CHECK(test::SameMemory(first.memory, second.memory)); + CHECK(first.trace.candidates.size() == second.trace.candidates.size()); + for (std::size_t i = 0; i < first.trace.candidates.size(); ++i) { + CHECK(test::SameEntry(first.trace.candidates[i], second.trace.candidates[i])); + } + // A repeated step at an equal timestamp is valid and changes nothing. + CHECK(first.output.kind == DecisionKind::Keep); + CHECK(test::SameMemory(first.memory, h.memory)); + + // Tracing does not change the decision or the next memory. + ObserverMemory memory = h.memory; + interest::StepScratch scratch; + interest::Reserve(scratch, 3); + Decision untraced{}; + CHECK(interest::Step(Observer(), candidates, profile, 100, memory, scratch, untraced) == + StepStatus::Ok); + CHECK(test::SameDecision(untraced, first.output)); + CHECK(test::SameMemory(memory, first.memory)); +} + +void C07_InvalidTimeObserverAndProfile() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); + const CandidateSnapshot candidates[]{PointAt(1, 5, 0, 0.9f, 0.9f)}; + + Harness h; + CHECK(h.Run(Observer(), candidates, profile, -50) == StepStatus::Ok); // any first time + CHECK(h.Run(Observer(), candidates, profile, 100) == StepStatus::Ok); + const ObserverMemory before = h.memory; + + // Backward time. + FillSentinels(h); + CHECK(h.Run(Observer(), candidates, profile, 99) == StepStatus::TimeWentBackwards); + CHECK(test::SameMemory(h.memory, before) && SentinelsIntact(h)); + + // Invalid observers. + const auto observer = [](auto change) { + ObserverSnapshot o = Observer(); + change(o); + return o; + }; + using EditObserver = ObserverSnapshot&; + const std::vector badObservers{ + observer([](EditObserver o) { o.position.x = kNaND; }), + observer([](EditObserver o) { o.position.z = -kInfD; }), + observer([](EditObserver o) { o.referenceForward = {0.f, 0.f, 0.f}; }), + observer([](EditObserver o) { o.referenceForward = {kNaN, 1.f, 0.f}; }), + observer([](EditObserver o) { o.referenceForward = {kInf, 0.f, 0.f}; }), + observer([](EditObserver o) { o.context = 1; }), // outside the profile's contexts + }; + for (const ObserverSnapshot& bad : badObservers) { + CHECK(h.Run(bad, candidates, profile, 200) == StepStatus::InvalidObserver); + CHECK(test::SameMemory(h.memory, before) && SentinelsIntact(h)); + } + + // Invalid profiles: ValidateProfile and Step agree, nothing is mutated. + std::vector badProfiles; + const auto addBadProfile = [&](auto mutate) { + AttentionProfile p = profile; + mutate(p); + badProfiles.push_back(p); + }; + addBadProfile([](AttentionProfile& p) { p.attractionWeight = -1.f; }); + addBadProfile([](AttentionProfile& p) { p.salienceWeight = kNaN; }); + addBadProfile([](AttentionProfile& p) { p.affinityWeight = kInf; }); + addBadProfile([](AttentionProfile& p) { + p.attractionWeight = 0.f; + p.salienceWeight = 0.f; + p.affinityWeight = 0.f; + }); + addBadProfile([](AttentionProfile& p) { p.fullRange = -1.f; }); + addBadProfile([](AttentionProfile& p) { p.fullRange = kNaN; }); + addBadProfile([](AttentionProfile& p) { p.maxRange = 0.f; }); + addBadProfile([](AttentionProfile& p) { p.maxRange = -5.f; }); + addBadProfile([](AttentionProfile& p) { p.maxRange = kInf; }); + addBadProfile([](AttentionProfile& p) { p.angleFloor = 1.5f; }); + addBadProfile([](AttentionProfile& p) { p.angleFloor = -0.1f; }); + addBadProfile([](AttentionProfile& p) { p.angleSharpness = -1.f; }); + addBadProfile([](AttentionProfile& p) { p.angleSharpness = kInf; }); + addBadProfile([](AttentionProfile& p) { p.acquireThreshold = 0.f; }); + addBadProfile([](AttentionProfile& p) { p.acquireThreshold = 1.5f; }); + addBadProfile([](AttentionProfile& p) { p.acquireThreshold = kNaN; }); + // Temporal policy. + addBadProfile([](AttentionProfile& p) { p.releaseThreshold = 0.f; }); + addBadProfile([](AttentionProfile& p) { p.releaseThreshold = -0.1f; }); + addBadProfile([](AttentionProfile& p) { p.releaseThreshold = kNaN; }); + addBadProfile([](AttentionProfile& p) { p.releaseThreshold = std::nextafter(0.3f, 1.f); }); + addBadProfile([](AttentionProfile& p) { // above an otherwise valid acquire threshold + p.acquireThreshold = 0.4f; + p.releaseThreshold = 0.5f; + }); + addBadProfile([](AttentionProfile& p) { p.switchMargin = 0.f; }); + addBadProfile([](AttentionProfile& p) { p.switchMargin = -0.1f; }); + addBadProfile([](AttentionProfile& p) { p.switchMargin = kNaN; }); + addBadProfile([](AttentionProfile& p) { + p.switchMargin = 1.5f; + p.overrideMargin = 1.5f; + }); + addBadProfile([](AttentionProfile& p) { // override below the switch margin + p.switchMargin = 0.2f; + p.overrideMargin = 0.1f; + }); + addBadProfile([](AttentionProfile& p) { p.overrideMargin = 1.5f; }); + addBadProfile([](AttentionProfile& p) { p.overrideMargin = kInf; }); + addBadProfile([](AttentionProfile& p) { p.minDwellMs = -1; }); + addBadProfile([](AttentionProfile& p) { p.contexts = {}; }); + ContextPreference overlapping{}; + overlapping.requiredTags = Tag(3); + overlapping.excludedTags = Tag(3) | Tag(4); + ContextPreference badFallback{}; + badFallback.fallbackAffinity = 1.5f; + ContextPreference badAffinity{}; + badAffinity.tagAffinity[63] = kNaN; // in an entry no observer uses yet + const ContextPreference overlapContexts[1]{overlapping}; + const ContextPreference fallbackContexts[1]{badFallback}; + const ContextPreference affinityContexts[2]{ContextPreference{}, badAffinity}; + addBadProfile([&](AttentionProfile& p) { p.contexts = overlapContexts; }); + addBadProfile([&](AttentionProfile& p) { p.contexts = fallbackContexts; }); + addBadProfile([&](AttentionProfile& p) { p.contexts = affinityContexts; }); + + CHECK(interest::ValidateProfile(profile) == StepStatus::Ok); + for (const AttentionProfile& bad : badProfiles) { + CHECK(interest::ValidateProfile(bad) == StepStatus::InvalidProfile); + CHECK(h.Run(Observer(), candidates, bad, 200) == StepStatus::InvalidProfile); + CHECK(test::SameMemory(h.memory, before) && SentinelsIntact(h)); + } + + // Validation order is profile, observer, time. + CHECK(h.Run(badObservers[0], candidates, badProfiles[0], 0) == StepStatus::InvalidProfile); + CHECK(h.Run(badObservers[0], candidates, profile, 0) == StepStatus::InvalidObserver); + CHECK(test::SameMemory(h.memory, before) && SentinelsIntact(h)); + + // After the failures the memory still accepts a valid step, and a reset forgets time. + CHECK(h.Run(Observer(), candidates, profile, 100) == StepStatus::Ok); + interest::ResetMemory(h.memory); + CHECK(h.memory.current == 0 && !h.memory.hasStepped); + CHECK(h.Run(Observer(), candidates, profile, 0) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Acquire); +} + +// Step never grows caller buffers: too little prepared capacity is a non-mutating error. +void CapacityContract() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); + const CandidateSnapshot candidates[]{PointAt(1, 5, 0, 0.9f, 0.9f), NonSpatial(2, 0.5f, 0.5f), + NonSpatial(3, 0.4f, 0.4f)}; + const CandidateSnapshot fewer[]{NonSpatial(2, 0.5f, 0.5f)}; + + Harness h; + CHECK(h.Run(Observer(), candidates, profile, 100) == StepStatus::Ok); // reserves 3 + const ObserverMemory before = h.memory; + + interest::StepScratch unprepared; + DecisionTrace unpreparedTrace; + FillSentinels(h); + const auto step = [&](interest::StepScratch& scratch, DecisionTrace* trace, + interest::TimeMs now) { + return interest::Step(Observer(), candidates, profile, now, h.memory, scratch, + h.output, trace); + }; + // Neither buffer, or only one of them, prepared. + CHECK(step(unprepared, &h.trace, 200) == StepStatus::InsufficientCapacity); + CHECK(step(h.scratch, &unpreparedTrace, 200) == StepStatus::InsufficientCapacity); + CHECK(test::SameMemory(h.memory, before) && SentinelsIntact(h)); + CHECK(unprepared.sortedIds.capacity() == 0 && unpreparedTrace.candidates.capacity() == 0); + + // Partially prepared scratch. + interest::StepScratch tooSmall; + interest::Reserve(tooSmall, 2); + CHECK(step(tooSmall, nullptr, 200) == StepStatus::InsufficientCapacity); + CHECK(test::SameMemory(h.memory, before) && SentinelsIntact(h)); + + // Earlier validation failures take precedence over capacity. + CHECK(step(unprepared, &h.trace, 99) == StepStatus::TimeWentBackwards); + CHECK(SentinelsIntact(h)); + + // No candidates need no capacity; no trace needs no trace capacity. + CHECK(interest::Step(Observer(), {}, profile, 200, h.memory, unprepared, h.output) == + StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Clear); + interest::StepScratch exact; + interest::Reserve(exact, 3); + CHECK(step(exact, nullptr, 300) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Acquire && h.output.selected == 1); + + // Prepared buffers are reused as-is: capacity and storage never change. + interest::StepScratch scratch; + DecisionTrace trace; + interest::Reserve(scratch, 3); + interest::Reserve(trace, 3); + interest::Reserve(scratch, 1); // never shrinks + const std::size_t scratchCapacity = scratch.sortedIds.capacity(); + const std::size_t traceCapacity = trace.candidates.capacity(); + const interest::CandidateId* scratchData = scratch.sortedIds.data(); + const interest::CandidateTrace* traceData = trace.candidates.data(); + bool stable = scratchCapacity >= 3 && traceCapacity >= 3; + for (interest::TimeMs now = 400; now < 500; ++now) { + const std::span input = + now % 2 == 0 ? std::span(candidates) + : std::span(fewer); + if (interest::Step(Observer(), input, profile, now, h.memory, scratch, h.output, + &trace) != StepStatus::Ok || + scratch.sortedIds.capacity() != scratchCapacity || + trace.candidates.capacity() != traceCapacity || + scratch.sortedIds.data() != scratchData || trace.candidates.data() != traceData || + trace.candidates.size() != input.size()) { + stable = false; + } + } + CHECK(stable); +} + +void C07a_DuplicateCandidateIds() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); + const CandidateSnapshot b = PointAt(2, 5, 0, 0.7f, 0.7f); + const CandidateSnapshot c = PointAt(3, 7, 2, 0.5f, 0.5f); + + // Two entries share id 1; the strong one would otherwise win. + const CandidateSnapshot pair[]{PointAt(1, 5, 0, 1.f, 1.f), b, PointAt(1, 9, 0, 0.1f, 0.1f), + c}; + Harness h; + CHECK(h.Run(Observer(), pair, profile, 0) == StepStatus::Ok); + CHECK(h.trace.candidates[0].eligibility == Eligibility::DuplicateId); + CHECK(h.trace.candidates[2].eligibility == Eligibility::DuplicateId); + CHECK(h.trace.candidates[0].rawScore == 0.f && h.trace.candidates[2].rawScore == 0.f); + CHECK(h.output.selected == 2); + + // Unrelated candidates are evaluated exactly as without the duplicates. + const CandidateSnapshot unrelated[]{b, c}; + Harness clean; + CHECK(clean.Run(Observer(), unrelated, profile, 0) == StepStatus::Ok); + CHECK(test::SameEntry(*h.Find(2), *clean.Find(2))); + CHECK(test::SameEntry(*h.Find(3), *clean.Find(3))); + CHECK(test::SameDecision(h.output, clean.output)); + + // Three occurrences, including a disabled one and one with an invalid value: all + // are DuplicateId. + CandidateSnapshot disabledCopy = PointAt(1, 5, 0, 1.f, 1.f); + disabledCopy.enabled = false; + CandidateSnapshot invalidCopy = PointAt(1, 5, 0, 1.f, 1.f); + invalidCopy.attraction = kNaN; + const CandidateSnapshot triple[]{disabledCopy, b, PointAt(1, 5, 0, 1.f, 1.f), invalidCopy}; + Harness three; + CHECK(three.Run(Observer(), triple, profile, 0) == StepStatus::Ok); + CHECK(three.trace.candidates[0].eligibility == Eligibility::DuplicateId); + CHECK(three.trace.candidates[2].eligibility == Eligibility::DuplicateId); + CHECK(three.trace.candidates[3].eligibility == Eligibility::DuplicateId); + CHECK(three.output.selected == 2); + + // A 3-way and a 2-way duplicate group in every order (7! = 5040 permutations). + const std::vector groups{ + PointAt(1, 5, 0, 1.f, 1.f), PointAt(1, 6, 0, 0.9f, 0.9f), NonSpatial(1, 1.f, 1.f), + PointAt(5, 5, 0, 1.f, 1.f), NonSpatial(5, 0.95f, 0.95f), c, PointAt(4, 6, 1, 0.6f, 0.6f)}; + Harness groupReference; + CHECK(groupReference.Run(Observer(), groups, profile, 0) == StepStatus::Ok); + CHECK(groupReference.output.selected == 4); + std::vector order(groups.size()); + for (std::size_t i = 0; i < order.size(); ++i) { + order[i] = i; + } + int permutations = 0; + bool allSame = true; + do { + std::vector permuted; + for (std::size_t from : order) { + permuted.push_back(groups[from]); + } + Harness run; + if (run.Run(Observer(), permuted, profile, 0) != StepStatus::Ok || + !test::SameDecision(run.output, groupReference.output)) { + allSame = false; + } + for (const interest::CandidateTrace& entry : run.trace.candidates) { + const bool duplicated = entry.id == 1 || entry.id == 5; + if (duplicated != (entry.eligibility == Eligibility::DuplicateId) || + !test::SameEntry(entry, *groupReference.Find(entry.id))) { + allSame = false; + } + } + ++permutations; + } while (std::next_permutation(order.begin(), order.end())); + CHECK(permutations == 5040); + CHECK(allSame); + + // A selected candidate whose id becomes duplicated is no longer selectable. + Harness selected; + const CandidateSnapshot first[]{PointAt(1, 5, 0, 1.f, 1.f), b}; + CHECK(selected.Run(Observer(), first, profile, 0) == StepStatus::Ok); + CHECK(selected.output.selected == 1); + const CandidateSnapshot second[]{PointAt(1, 5, 0, 1.f, 1.f), b, PointAt(1, 5, 0, 1.f, 1.f)}; + CHECK(selected.Run(Observer(), second, profile, 100) == StepStatus::Ok); + CHECK(selected.output.kind == DecisionKind::Switch && selected.output.selected == 2); + + // Repeated id 0 entries are reported as InvalidId, not DuplicateId. + const CandidateSnapshot zeros[]{NonSpatial(0, 1.f, 1.f), NonSpatial(0, 1.f, 1.f)}; + Harness zero; + CHECK(zero.Run(Observer(), zeros, profile, 0) == StepStatus::Ok); + CHECK(zero.trace.candidates[0].eligibility == Eligibility::InvalidId); + CHECK(zero.trace.candidates[1].eligibility == Eligibility::InvalidId); +} + +void C07b_GazeDoesNotFeedBackIntoAngleScore() +{ + const ContextPreference contexts[1]{}; + AttentionProfile profile = Profile(contexts); + profile.angleSharpness = 2.f; // make the angle term matter + + // Body forward +x. A is 36.9 degrees left, B 53.1 degrees right but more appealing. + const CandidateSnapshot a = PointAt(1, 8, 6, 0.5f, 0.5f); + const CandidateSnapshot b = PointAt(2, 6, -8, 0.8f, 0.8f); + const CandidateSnapshot candidates[]{a, b}; + const ObserverSnapshot body = Observer(); + + Harness h; + CHECK(h.Run(body, candidates, profile, 0) == StepStatus::Ok); + CHECK(h.output.selected == 2); + const DecisionTrace first = h.trace; + + // Being selected gives B no later angle advantage: Step has no gaze input and derives + // none from its previous selection. However the host's head turns toward B, the + // snapshot keeps the body baseline and every score stays bitwise identical. + for (interest::TimeMs now = 100; now <= 400; now += 100) { + CHECK(h.Run(body, candidates, profile, now) == StepStatus::Ok); + CHECK(h.output.kind == DecisionKind::Keep && h.output.selected == 2); + for (std::size_t i = 0; i < first.candidates.size(); ++i) { + CHECK(test::SameEntry(h.trace.candidates[i], first.candidates[i])); + } + } + + // Control: the forbidden wiring (gaze as referenceForward) lets whichever target the + // head already looks at win, so the invariance above is not vacuous. + ObserverSnapshot gazeAtA = body; + gazeAtA.referenceForward = {0.8f, 0.6f, 0.f}; + Harness wiredA; + CHECK(wiredA.Run(gazeAtA, candidates, profile, 0) == StepStatus::Ok); + CHECK(wiredA.output.selected == 1); + CHECK(test::Near(wiredA.Find(1)->angleGate, 1.0, 1e-6)); + CHECK(wiredA.Find(1)->angleGate > first.candidates[0].angleGate); + + ObserverSnapshot gazeAtB = body; + gazeAtB.referenceForward = {0.6f, -0.8f, 0.f}; + Harness wiredB; + CHECK(wiredB.Run(gazeAtB, candidates, profile, 0) == StepStatus::Ok); + CHECK(wiredB.output.selected == 2); + CHECK(wiredB.Find(2)->rawScore > first.candidates[1].rawScore); +} + +void C16_ObserversInDifferentContexts() +{ + constexpr unsigned kShop = 0; + constexpr unsigned kPerson = 1; + ContextPreference shopper{}; + shopper.affinityTags = Tag(kShop); + shopper.tagAffinity[kShop] = 1.f; + shopper.fallbackAffinity = 0.2f; + ContextPreference guard{}; + guard.affinityTags = Tag(kPerson); + guard.tagAffinity[kPerson] = 1.f; + guard.fallbackAffinity = 0.2f; + guard.excludedTags = Tag(kShop); + const ContextPreference contexts[2]{shopper, guard}; + const AttentionProfile profile = Profile(contexts); + + CandidateSnapshot display = PointAt(10, 6, 1, 0.5f, 0.3f); + display.tags = Tag(kShop); + CandidateSnapshot passerBy = PointAt(20, 6, -1, 0.5f, 0.3f); + passerBy.tags = Tag(kPerson); + const CandidateSnapshot candidates[]{display, passerBy}; + + Harness shopperState; + Harness guardState; + CHECK(shopperState.Run(Observer(0), candidates, profile, 0) == StepStatus::Ok); + CHECK(guardState.Run(Observer(1), candidates, profile, 0) == StepStatus::Ok); + CHECK(shopperState.output.selected == 10); + CHECK(guardState.output.selected == 20); + CHECK(shopperState.Find(10)->affinity == 1.f && shopperState.Find(20)->affinity == 0.2f); + CHECK(guardState.Find(10)->eligibility == Eligibility::ExcludedTag); + CHECK(guardState.Find(20)->affinity == 1.f); +} + +void DecisionLabelsAcrossSteps() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); + Harness h; + const auto expect = [&h](DecisionKind kind, DecisionReason reason, + interest::CandidateId selected, interest::CandidateId previous) { + CHECK(h.output.kind == kind); + CHECK(h.output.reason == reason); + CHECK(h.output.selected == selected && h.output.previous == previous); + CHECK(h.memory.current == selected); + }; + + const CandidateSnapshot low[]{NonSpatial(1, 0.f, 0.f)}; + const CandidateSnapshot one[]{NonSpatial(1, 0.9f, 0.9f), NonSpatial(2, 0.5f, 0.5f)}; + const CandidateSnapshot two[]{NonSpatial(1, 0.5f, 0.5f), NonSpatial(2, 0.9f, 0.9f)}; + + CHECK(h.Run(Observer(), low, profile, 0) == StepStatus::Ok); + expect(DecisionKind::None, DecisionReason::BelowAcquireThreshold, 0, 0); + CHECK(h.Run(Observer(), one, profile, 100) == StepStatus::Ok); + expect(DecisionKind::Acquire, DecisionReason::HighestScore, 1, 0); + CHECK(h.Run(Observer(), one, profile, 200) == StepStatus::Ok); + expect(DecisionKind::Keep, DecisionReason::HighestScore, 1, 1); + // Switch and Clear name the policy rule: candidate 2 leads by more than the + // switch margin, and then target 2 is absent from the snapshot. + CHECK(h.Run(Observer(), two, profile, 300) == StepStatus::Ok); + expect(DecisionKind::Switch, DecisionReason::BetterCandidate, 2, 1); + CHECK(h.Run(Observer(), low, profile, 400) == StepStatus::Ok); + expect(DecisionKind::Clear, DecisionReason::CurrentInvalid, 0, 2); + CHECK(h.Run(Observer(), {}, profile, 500) == StepStatus::Ok); + expect(DecisionKind::None, DecisionReason::NoEligibleCandidate, 0, 0); +} + +} // namespace + +int main() +{ + test::Run("C01 empty candidate list", C01_EmptyCandidateList); + test::Run("C02 invalid, disabled and out-of-range candidates", + C02_InvalidDisabledAndOutOfRangeCandidates); + test::Run("C03 acquisition and no-target behavior", C03_AcquisitionAndNoTarget); + test::Run("C04 candidate permutations and exact ties", C04_PermutationsAndExactTies); + test::Run("C05 static and live-updated snapshots", C05_StaticAndLiveUpdatedSnapshots); + test::Run("C06 identical inputs, state and time", C06_IdenticalInputsStateAndTime); + test::Run("C07 invalid time, observer and profile", C07_InvalidTimeObserverAndProfile); + test::Run("capacity contract", CapacityContract); + test::Run("C07a duplicate candidate ids", C07a_DuplicateCandidateIds); + test::Run("C07b gaze does not feed back into angle score", + C07b_GazeDoesNotFeedBackIntoAngleScore); + test::Run("C16 observers in different contexts", C16_ObserversInDifferentContexts); + test::Run("decision labels across steps", DecisionLabelsAcrossSteps); + return test::Finish("interest_scenario_test"); +} diff --git a/tests/step_scoring_test.cpp b/tests/step_scoring_test.cpp new file mode 100644 index 0000000..361b8b9 --- /dev/null +++ b/tests/step_scoring_test.cpp @@ -0,0 +1,439 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Checks geometric gates and contextual weighted-mean scoring against explicit formulas. + +#include "test_support.hpp" + +#include +#include +#include + +namespace { + +using interest::AttentionProfile; +using interest::CandidateSnapshot; +using interest::CandidateTrace; +using interest::ContextPreference; +using interest::Eligibility; +using interest::ObserverSnapshot; +using interest::SpatialKind; +using interest::StepStatus; +using interest::TagMask; +using test::Harness; +using test::Near; +using test::NonSpatial; +using test::Observer; +using test::PointAt; +using test::Profile; + +constexpr double kTolerance = 1e-6; + +constexpr TagMask Tag(unsigned bit) +{ + return TagMask{1} << bit; +} + +// Evaluates a single candidate and returns its trace entry. +CandidateTrace Evaluate(const CandidateSnapshot& candidate, const AttentionProfile& profile, + const ObserverSnapshot& observer = Observer()) +{ + Harness h; + const CandidateSnapshot candidates[]{candidate}; + if (h.Run(observer, candidates, profile, 0) != StepStatus::Ok || + h.trace.candidates.size() != 1) { + return CandidateTrace{}; + } + return h.trace.candidates[0]; +} + +double SmoothStep(double edge0, double edge1, double x) +{ + double t = (x - edge0) / (edge1 - edge0); + t = t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t); + return t * t * (3.0 - 2.0 * t); +} + +void PointScoreMatchesDesignFormula() +{ + ContextPreference context{}; + context.affinityTags = Tag(2); + context.tagAffinity[2] = 0.8f; + const ContextPreference contexts[1]{context}; + const AttentionProfile profile = Profile(contexts); + + CandidateSnapshot candidate = PointAt(1, 13, 4, 0.7f, 0.4f); + candidate.perceptionStrength = 0.9f; + candidate.tags = Tag(2); + const CandidateTrace entry = Evaluate(candidate, profile); + + const double distance = std::sqrt(13.0 * 13.0 + 4.0 * 4.0); + const double distanceGate = 1.0 - SmoothStep(10.0, 20.0, distance); + const double cosPhi = 13.0 / distance; + const double angleFloor = static_cast(0.2f); + const double angleGate = angleFloor + (1.0 - angleFloor) * ((1.0 + cosPhi) / 2.0); + const double gate = distanceGate * angleGate * static_cast(0.9f); + const double preference = + (static_cast(0.7f) + static_cast(0.4f) + static_cast(0.8f)) / + 3.0; + + CHECK(entry.eligibility == Eligibility::Eligible); + CHECK(Near(entry.distance, distance, 1e-12)); + CHECK(Near(entry.distanceGate, distanceGate, kTolerance)); + CHECK(Near(entry.angleGate, angleGate, kTolerance)); + CHECK(Near(entry.gate, gate, kTolerance)); + CHECK(entry.affinity == 0.8f); + CHECK(Near(entry.preference, preference, kTolerance)); + CHECK(Near(entry.rawScore, gate * preference, kTolerance)); + CHECK(entry.attraction == 0.7f && entry.salience == 0.4f && + entry.perceptionStrength == 0.9f); +} + +void DistanceGate() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); // fullRange 10, maxRange 20 + + CHECK(Evaluate(PointAt(1, 5, 0, 1.f, 1.f), profile).distanceGate == 1.f); + CHECK(Evaluate(PointAt(1, 10, 0, 1.f, 1.f), profile).distanceGate == 1.f); + CHECK(Near(Evaluate(PointAt(1, 15, 0, 1.f, 1.f), profile).distanceGate, 0.5, kTolerance)); + + // At the cutoff the candidate is still eligible but scores 0; beyond it, rejected. + const CandidateTrace atCutoff = Evaluate(PointAt(1, 20, 0, 1.f, 1.f), profile); + CHECK(atCutoff.eligibility == Eligibility::Eligible); + CHECK(atCutoff.distanceGate == 0.f && atCutoff.rawScore == 0.f); + CHECK(Evaluate(PointAt(1, 20.001, 0, 1.f, 1.f), profile).eligibility == + Eligibility::OutOfRange); + + // A coincident point has no direction; it is treated as straight ahead. + const CandidateTrace coincident = Evaluate(PointAt(1, 0, 0, 1.f, 1.f), profile); + CHECK(coincident.distance == 0.0 && coincident.angleGate == 1.f); + + // fullRange >= cutoff: no falloff until the hard cutoff. + AttentionProfile noFalloff = Profile(contexts); + noFalloff.fullRange = 30.f; + CHECK(Evaluate(PointAt(1, 19.9, 0, 1.f, 1.f), noFalloff).distanceGate == 1.f); + CHECK(Evaluate(PointAt(1, 20.1, 0, 1.f, 1.f), noFalloff).eligibility == + Eligibility::OutOfRange); + + // A candidate's own maxDistance replaces the profile cutoff. + CandidateSnapshot farReaching = PointAt(1, 30, 0, 1.f, 1.f); + farReaching.maxDistance = 40.f; + const CandidateTrace far = Evaluate(farReaching, profile); + CHECK(far.eligibility == Eligibility::Eligible); + CHECK(Near(far.distanceGate, 1.0 - SmoothStep(10.0, 40.0, 30.0), kTolerance)); + + // Bearing and NonSpatial candidates have no distance penalty. + CandidateSnapshot bearing = PointAt(2, 1000, 0, 1.f, 1.f); + bearing.spatial = SpatialKind::Bearing; + bearing.bearing = {1.f, 0.f, 0.f}; + CHECK(Evaluate(bearing, profile).eligibility == Eligibility::Eligible); + CHECK(Evaluate(bearing, profile).distanceGate == 1.f); + CandidateSnapshot nonSpatial = NonSpatial(3, 1.f, 1.f); + nonSpatial.position = {1000.0, 0.0, 0.0}; + CHECK(Evaluate(nonSpatial, profile).distanceGate == 1.f); +} + +void AngleGate() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = Profile(contexts); // floor 0.2, sharpness 1 + + CHECK(Near(Evaluate(PointAt(1, 5, 0, 1.f, 1.f), profile).angleGate, 1.0, kTolerance)); + CHECK(Near(Evaluate(PointAt(1, 0, 5, 1.f, 1.f), profile).angleGate, 0.6, kTolerance)); + // Behind the observer only the floor remains, so a perceived stimulus stays relevant. + const CandidateTrace behind = Evaluate(PointAt(1, -5, 0, 1.f, 1.f), profile); + CHECK(behind.eligibility == Eligibility::Eligible); + CHECK(Near(behind.angleGate, 0.2, kTolerance)); + + AttentionProfile sharp = Profile(contexts); + sharp.angleSharpness = 2.f; + CHECK(Near(Evaluate(PointAt(1, 0, 5, 1.f, 1.f), sharp).angleGate, 0.4, kTolerance)); + + AttentionProfile flat = Profile(contexts); + flat.angleSharpness = 0.f; // no angular preference, even directly behind + CHECK(Evaluate(PointAt(1, -5, 0, 1.f, 1.f), flat).angleGate == 1.f); + + AttentionProfile noFloor = Profile(contexts); + noFloor.angleFloor = 0.f; + const CandidateTrace hidden = Evaluate(PointAt(1, -5, 0, 1.f, 1.f), noFloor); + CHECK(hidden.eligibility == Eligibility::Eligible && hidden.rawScore == 0.f); + + // Bearing directions and referenceForward may have any nonzero length. + CandidateSnapshot bearing = NonSpatial(2, 1.f, 1.f); + bearing.spatial = SpatialKind::Bearing; + bearing.bearing = {0.f, 3.f, 0.f}; + CHECK(Near(Evaluate(bearing, profile).angleGate, 0.6, kTolerance)); + + ObserverSnapshot unit = Observer(); + unit.referenceForward = {0.6f, 0.8f, 0.f}; + ObserverSnapshot scaled = Observer(); + scaled.referenceForward = {6.f, 8.f, 0.f}; + const CandidateSnapshot point = PointAt(3, 4, -7, 0.6f, 0.9f); + CHECK(Near(Evaluate(point, profile, unit).rawScore, + static_cast(Evaluate(point, profile, scaled).rawScore), kTolerance)); + + // NonSpatial: the gate is perception strength alone. + CandidateSnapshot faint = NonSpatial(4, 1.f, 1.f); + faint.perceptionStrength = 0.4f; + const CandidateTrace faintEntry = Evaluate(faint, profile); + CHECK(faintEntry.angleGate == 1.f && Near(faintEntry.gate, 0.4, kTolerance)); +} + +void ContextAffinity() +{ + ContextPreference context{}; + context.affinityTags = Tag(2) | Tag(5); + context.tagAffinity[2] = 0.3f; + context.tagAffinity[5] = 0.9f; + context.tagAffinity[7] = 1.f; // not in affinityTags, so never used + context.fallbackAffinity = 0.1f; + const ContextPreference contexts[1]{context}; + const AttentionProfile profile = Profile(contexts); + + const auto affinityFor = [&profile](TagMask tags) { + CandidateSnapshot candidate = NonSpatial(1, 0.5f, 0.5f); + candidate.tags = tags; + return Evaluate(candidate, profile).affinity; + }; + CHECK(affinityFor(Tag(2)) == 0.3f); + CHECK(affinityFor(Tag(2) | Tag(5)) == 0.9f); // maximum matching affinity + CHECK(affinityFor(Tag(5) | Tag(7)) == 0.9f); + CHECK(affinityFor(Tag(7)) == 0.1f); // no tag with a defined affinity + CHECK(affinityFor(0) == 0.1f); // untagged +} + +void HigherInputsNeverLowerRawScore() +{ + ContextPreference context{}; + context.affinityTags = Tag(0); + const ContextPreference contexts[1]{context}; + const AttentionProfile profile = Profile(contexts); + + test::Random rng(0x5EED0001u); + bool monotonic = true; + for (int sample = 0; sample < 2000; ++sample) { + // Draw values in a fixed order; argument evaluation order is unspecified. + CandidateSnapshot base = PointAt(1, 0, 0, 0.f, 0.f); + base.position.x = static_cast(rng.Below(41)) - 20.0; + base.position.y = static_cast(rng.Below(41)) - 20.0; + base.attraction = rng.Unit(); + base.salience = rng.Unit(); + base.perceptionStrength = rng.Unit(); + base.tags = Tag(0); + ContextPreference lowContext = context; + lowContext.tagAffinity[0] = rng.Unit(); + const ContextPreference low[1]{lowContext}; + AttentionProfile lowProfile = profile; + lowProfile.contexts = low; + const CandidateTrace baseEntry = Evaluate(base, lowProfile); + if (baseEntry.eligibility != Eligibility::Eligible) { + continue; + } + + CandidateSnapshot more = base; + switch (rng.Below(4)) { + case 0: more.attraction = rng.Unit() * (1.f - base.attraction) + base.attraction; break; + case 1: more.salience = rng.Unit() * (1.f - base.salience) + base.salience; break; + case 2: + more.perceptionStrength = + rng.Unit() * (1.f - base.perceptionStrength) + base.perceptionStrength; + break; + default: { + ContextPreference highContext = lowContext; + highContext.tagAffinity[0] = 1.f; + const ContextPreference high[1]{highContext}; + AttentionProfile highProfile = profile; + highProfile.contexts = high; + if (Evaluate(more, highProfile).rawScore < baseEntry.rawScore) { + monotonic = false; + } + continue; + } + } + if (Evaluate(more, lowProfile).rawScore < baseEntry.rawScore) { + monotonic = false; + } + } + CHECK(monotonic); +} + +// Generates small scenes with deliberate id collisions, invalid values, disabled and +// unperceived candidates, all three spatial kinds and tag rules. Each scene must give the +// same decision and per-id trace for several orders, and satisfy the score invariants. +void GeneratedScenesArePermutationInvariant() +{ + ContextPreference picky{}; + picky.requiredTags = Tag(0); + picky.excludedTags = Tag(3); + picky.affinityTags = Tag(0) | Tag(1); + picky.tagAffinity[0] = 0.4f; + picky.tagAffinity[1] = 0.9f; + ContextPreference unrestricted{}; + const ContextPreference contexts[2]{unrestricted, picky}; + + test::Random rng(0x5EED0002u); + int scenes = 0; + int selections = 0; + int duplicates = 0; + bool invariant = true; + for (; scenes < 3000; ++scenes) { + AttentionProfile profile = Profile(contexts); + profile.acquireThreshold = 0.05f + 0.5f * rng.Unit(); + profile.releaseThreshold = profile.acquireThreshold; // may not exceed acquire + profile.angleSharpness = 3.f * rng.Unit(); + ObserverSnapshot observer = Observer(static_cast(rng.Below(2))); + observer.referenceForward = {rng.Unit() - 0.5f, rng.Unit() - 0.5f, 0.1f}; + + std::vector candidates(static_cast(rng.Below(10))); + for (CandidateSnapshot& c : candidates) { + c.id = rng.Below(13); // 0..12: id 0 and collisions are common + c.spatial = static_cast(rng.Below(3)); + c.position = {static_cast(rng.Below(51)) - 25.0, + static_cast(rng.Below(51)) - 25.0, 0.0}; + c.bearing = {rng.Unit() - 0.5f, rng.Unit() - 0.5f, 0.f}; + c.attraction = rng.Unit(); + c.salience = rng.Unit(); + c.perceptionStrength = rng.Below(8) == 0 ? 0.f : rng.Unit(); + c.maxDistance = rng.Below(4) == 0 ? 30.f : 0.f; + c.tags = rng.Below(16); + c.enabled = rng.Below(10) != 0; + if (rng.Below(20) == 0) { + c.salience = 2.f; // invalid value + } + } + + Harness reference; + if (reference.Run(observer, candidates, profile, 0) != StepStatus::Ok) { + invariant = false; + continue; + } + const interest::Decision& decision = reference.output; + + // Score invariants on the reference order. + float bestScore = 0.f; + interest::CandidateId best = 0; + for (const CandidateTrace& entry : reference.trace.candidates) { + if (entry.eligibility == Eligibility::DuplicateId) { + ++duplicates; + } + if (entry.eligibility != Eligibility::Eligible) { + if (entry.rawScore != 0.f || + (decision.selected != 0 && entry.id == decision.selected)) { + invariant = false; + } + continue; + } + if (!std::isfinite(entry.rawScore) || entry.rawScore < 0.f || entry.rawScore > 1.f) { + invariant = false; + } + if (best == 0 || entry.rawScore > bestScore || + (entry.rawScore == bestScore && entry.id < best)) { + best = entry.id; + bestScore = entry.rawScore; + } + } + if (reference.trace.leading != best || reference.trace.leadingScore != bestScore) { + invariant = false; + } + if (decision.selected != 0) { + ++selections; + if (decision.selected != best || decision.rawScore != bestScore || + decision.rawScore < profile.acquireThreshold) { + invariant = false; + } + } else if (best != 0 && bestScore >= profile.acquireThreshold) { + invariant = false; + } + + // Same result for reversed, rotated and shuffled orders. + for (int orderKind = 0; orderKind < 3; ++orderKind) { + std::vector permuted = candidates; + if (orderKind == 0) { + std::reverse(permuted.begin(), permuted.end()); + } else if (orderKind == 1 && !permuted.empty()) { + std::rotate(permuted.begin(), permuted.begin() + 1, permuted.end()); + } else { + rng.Shuffle(permuted); + } + Harness h; + if (h.Run(observer, permuted, profile, 0) != StepStatus::Ok || + !test::SameDecision(h.output, decision) || + !test::SameMemory(h.memory, reference.memory) || + h.trace.leading != reference.trace.leading) { + invariant = false; + continue; + } + for (const CandidateTrace& entry : h.trace.candidates) { + const CandidateTrace* original = reference.Find(entry.id); + if (original == nullptr || !test::SameEntry(entry, *original)) { + invariant = false; + } + } + } + } + CHECK(invariant); + // Guard against a generator that never exercises the interesting paths. + CHECK(selections > scenes / 10 && selections < scenes * 9 / 10); + CHECK(duplicates > scenes / 2); + std::printf(" generated scenes: %d, with selection: %d, duplicate entries: %d\n", scenes, + selections, duplicates); +} + +// The weighted mean is normalized, so only weight ratios matter. Scaling every weight by a +// power of two is exact in floating point, so scores and decisions must be bitwise equal. +void WeightScaleInvariance() +{ + const ContextPreference contexts[1]{}; + test::Random rng(0x5EED0004u); + bool invariant = true; + for (int scene = 0; scene < 500; ++scene) { + AttentionProfile profile = Profile(contexts); + profile.attractionWeight = rng.Unit(); + profile.salienceWeight = rng.Unit() + 0.25f; + profile.affinityWeight = rng.Unit(); + AttentionProfile scaled = profile; + scaled.attractionWeight *= 8.f; + scaled.salienceWeight *= 8.f; + scaled.affinityWeight *= 8.f; + + std::vector candidates(static_cast(rng.Below(6))); + interest::CandidateId id = 1; + for (CandidateSnapshot& c : candidates) { + c = PointAt(id++, 0, 0, 0.f, 0.f); + c.position.x = static_cast(rng.Below(31)) - 10.0; + c.position.y = static_cast(rng.Below(31)) - 15.0; + c.attraction = rng.Unit(); + c.salience = rng.Unit(); + } + Harness a; + Harness b; + if (a.Run(Observer(), candidates, profile, 0) != StepStatus::Ok || + b.Run(Observer(), candidates, scaled, 0) != StepStatus::Ok || + !test::SameDecision(a.output, b.output)) { + invariant = false; + continue; + } + for (std::size_t i = 0; i < candidates.size(); ++i) { + if (!test::SameEntry(a.trace.candidates[i], b.trace.candidates[i])) { + invariant = false; + } + } + } + CHECK(invariant); +} + +} // namespace + +int main() +{ + test::Run("point score matches design formula", PointScoreMatchesDesignFormula); + test::Run("distance gate", DistanceGate); + test::Run("angle gate", AngleGate); + test::Run("context affinity", ContextAffinity); + test::Run("higher inputs never lower raw score", HigherInputsNeverLowerRawScore); + test::Run("generated scenes are permutation invariant", + GeneratedScenesArePermutationInvariant); + test::Run("weight scale invariance", WeightScaleInvariance); + return test::Finish("interest_scoring_test"); +} diff --git a/tests/step_temporal_test.cpp b/tests/step_temporal_test.cpp new file mode 100644 index 0000000..bf5932a --- /dev/null +++ b/tests/step_temporal_test.cpp @@ -0,0 +1,616 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +// Temporal-policy scenarios: hysteresis, dwell, switching, override and invalid targets. +// Coverage includes dwell boundaries, invalidation during dwell, exact ties, +// explicit time and deterministic replay over generated multi-step streams. +// Most cases use test::TemporalProfile (acquire 0.5, release 0.375, switch margin +// 0.125, override margin 0.25, dwell 500 ms) with test::Scored candidates, whose raw +// score equals the supplied value. These dyadic values make threshold and margin +// boundary comparisons exact. + +#include "test_support.hpp" + +#include +#include +#include + +namespace { + +using interest::AttentionProfile; +using interest::CandidateId; +using interest::CandidateSnapshot; +using interest::ContextPreference; +using interest::DecisionKind; +using interest::DecisionReason; +using interest::ObserverMemory; +using interest::StepStatus; +using interest::TagMask; +using interest::TimeMs; +using test::Harness; +using test::Observer; +using test::Scored; +using test::StatelessSelection; +using test::TemporalProfile; + +TimeMs At(int step) +{ + return static_cast(step) * 100; // 10 Hz +} + +// C08: two sources trade the lead between 0.71 and 0.69 on every step, at +// 10 Hz for 10 s. Stateless selection flips on every step; the policy never switches. +void C08_NearEqualScoreNoise() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = TemporalProfile(contexts); + Harness h; + int switches = 0; + int statelessSwitches = 0; + CandidateId statelessPrevious = 0; + for (int step = 0; step < 100; ++step) { + const bool firstLeads = step % 2 == 0; + const CandidateSnapshot candidates[]{Scored(1, firstLeads ? 0.71f : 0.69f), + Scored(2, firstLeads ? 0.69f : 0.71f)}; + CHECK(h.Run(Observer(), candidates, profile, At(step)) == StepStatus::Ok); + if (step == 0) { + CHECK_DECISION(h, Acquire, HighestScore, 1); + } else if (firstLeads) { + CHECK_DECISION(h, Keep, HighestScore, 1); + } else { + // Also during dwell: dwell is not the reason, the lead is below switchMargin. + CHECK_DECISION(h, Keep, InsufficientAdvantage, 1); + } + switches += h.output.kind == DecisionKind::Switch ? 1 : 0; + const CandidateId stateless = StatelessSelection(h.trace); + statelessSwitches += step > 0 && stateless != statelessPrevious ? 1 : 0; + statelessPrevious = stateless; + } + CHECK(switches == 0); + CHECK(statelessSwitches == 99); +} + +// C09: one source alternates between 0.5625 and 0.4375, around acquire 0.5 and never +// below release 0.375. +void C09_AcquireReleaseThresholdOscillation() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = TemporalProfile(contexts); + const auto scoreAt = [](int step) { return step % 2 == 1 ? 0.5625f : 0.4375f; }; + + Harness h; + int statelessChanges = 0; + CandidateId statelessPrevious = 0; + for (int step = 0; step <= 20; ++step) { + const CandidateSnapshot candidates[]{Scored(1, scoreAt(step))}; + CHECK(h.Run(Observer(), candidates, profile, At(step)) == StepStatus::Ok); + if (step == 0) { + // The band only holds a current target; acquisition still needs 0.5. + CHECK_DECISION(h, None, BelowAcquireThreshold, 0); + } else if (step == 1) { + CHECK_DECISION(h, Acquire, HighestScore, 1); + } else if (step % 2 == 1) { + CHECK_DECISION(h, Keep, HighestScore, 1); + } else { + CHECK_DECISION(h, Keep, AboveReleaseThreshold, 1); // inside dwell as well + } + const CandidateId stateless = StatelessSelection(h.trace); + statelessChanges += stateless != statelessPrevious ? 1 : 0; + statelessPrevious = stateless; + } + CHECK(statelessChanges == 20); // stateless selection acquires or clears on every step + + // Falling below release clears it, since dwell has long passed. + const CandidateSnapshot low[]{Scored(1, 0.25f)}; + CHECK(h.Run(Observer(), low, profile, At(21)) == StepStatus::Ok); + CHECK_DECISION(h, Clear, BelowReleaseThreshold, 0); + CHECK(h.memory.current == 0 && h.memory.focusStartTime == 0); + + // Ablation: without the band (release == acquire), dwell only slows the flicker. The + // source is acquired at 100, 700, 1300 and 1900 ms and released when each dwell ends. + AttentionProfile noBand = profile; + noBand.releaseThreshold = noBand.acquireThreshold; + Harness ablated; + int clears = 0; + int dwellHolds = 0; + for (int step = 0; step <= 20; ++step) { + const CandidateSnapshot candidates[]{Scored(1, scoreAt(step))}; + CHECK(ablated.Run(Observer(), candidates, noBand, At(step)) == StepStatus::Ok); + clears += ablated.output.kind == DecisionKind::Clear ? 1 : 0; + dwellHolds += ablated.output.reason == DecisionReason::MinimumDwell ? 1 : 0; + } + CHECK(clears == 3); + CHECK(dwellHolds == 7); +} + +// C10: the current target becomes invalid while dwell still runs. Every kind of +// invalidation drops it on that same step, replacing it when a candidate qualifies. +void C10_CurrentTargetInvalidDuringDwell() +{ + constexpr unsigned kExcludedBit = 3; + ContextPreference neutral{}; + ContextPreference excluding{}; + excluding.excludedTags = TagMask{1} << kExcludedBit; + const ContextPreference contexts[2]{neutral, excluding}; + const AttentionProfile profile = TemporalProfile(contexts); + + // A Point straight ahead within fullRange: its gate is 1, so it scores its attraction. + CandidateSnapshot target = test::PointAt(1, 5, 0, 0.75f, 0.f); + target.tags = TagMask{1} << kExcludedBit; + const auto edited = [&target](auto edit) { + CandidateSnapshot copy = target; + edit(copy); + return copy; + }; + using Edit = CandidateSnapshot&; + struct Variant { + const char* name; + std::vector candidates; // the target's entries at 1100 ms + interest::ContextId context; + }; + const std::vector variants{ + {"absent", {}, 0}, + {"disabled", {edited([](Edit c) { c.enabled = false; })}, 0}, + {"not perceived", {edited([](Edit c) { c.perceptionStrength = 0.f; })}, 0}, + {"invalid value", + {edited([](Edit c) { c.attraction = std::numeric_limits::quiet_NaN(); })}, 0}, + {"invalid geometry", + {edited([](Edit c) { c.position.x = std::numeric_limits::infinity(); })}, 0}, + {"out of range", {edited([](Edit c) { c.position.x = 25.0; })}, 0}, + {"duplicated id", {target, target}, 0}, + {"excluded by the new context", {target}, 1}, + }; + const CandidateSnapshot qualifying = Scored(2, 0.625f); + const CandidateSnapshot weak = Scored(2, 0.4375f); // eligible, below acquire + + for (const Variant& variant : variants) { + for (const bool withFallback : {true, false}) { + const int failuresBefore = test::State().failures; + const CandidateSnapshot& other = withFallback ? qualifying : weak; + Harness h; + const CandidateSnapshot start[]{target, other}; + CHECK(h.Run(Observer(), start, profile, 1000) == StepStatus::Ok); + CHECK_DECISION(h, Acquire, HighestScore, 1); + + std::vector next = variant.candidates; + next.push_back(other); + CHECK(h.Run(Observer(variant.context), next, profile, 1100) == StepStatus::Ok); + if (withFallback) { + CHECK_DECISION(h, Switch, CurrentInvalid, 2); + CHECK(h.output.rawScore == 0.625f && h.memory.focusStartTime == 1100); + } else { + CHECK_DECISION(h, Clear, CurrentInvalid, 0); + CHECK(h.memory.current == 0 && h.memory.focusStartTime == 0); + } + // The trace shows that dwell was still running and did not protect it. + CHECK(!h.trace.currentEligible && h.trace.currentScore == 0.f); + CHECK(h.trace.dwellRemainingMs == 400); + if (test::State().failures != failuresBefore) { + std::fprintf(stderr, " variant: %s, %s fallback\n", variant.name, + withFallback ? "with" : "without"); + } + } + } + + // Contrast: a still-valid target below release is held until dwell ends. Its + // challenger leads by more than overrideMargin but is below acquireThreshold, so it + // cannot override (C11a) or replace it afterwards. + Harness valid; + const CandidateSnapshot first[]{Scored(1, 0.75f), Scored(2, 0.4375f)}; + CHECK(valid.Run(Observer(), first, profile, 1000) == StepStatus::Ok); + const CandidateSnapshot faded[]{Scored(1, 0.125f), Scored(2, 0.4375f)}; + CHECK(valid.Run(Observer(), faded, profile, 1100) == StepStatus::Ok); + CHECK_DECISION(valid, Keep, MinimumDwell, 1); + CHECK(valid.trace.currentEligible && valid.trace.currentScore == 0.125f); + CHECK(valid.trace.dwellRemainingMs == 400); + CHECK(valid.Run(Observer(), faded, profile, 1499) == StepStatus::Ok); + CHECK_DECISION(valid, Keep, MinimumDwell, 1); + CHECK(valid.Run(Observer(), faded, profile, 1500) == StepStatus::Ok); + CHECK_DECISION(valid, Clear, BelowReleaseThreshold, 0); +} + +// C11: a challenger that persists above switchMargin replaces the target once dwell +// ends; one below the margin, or below acquireThreshold, never does. +void C11_PersistentChallenger() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = TemporalProfile(contexts); + const CandidateSnapshot alone[]{Scored(1, 0.625f)}; + + Harness h; + const auto pair = [&h, &profile](float first, float second, TimeMs now) { + const CandidateSnapshot candidates[]{Scored(1, first), Scored(2, second)}; + return h.Run(Observer(), candidates, profile, now); + }; + CHECK(h.Run(Observer(), alone, profile, 0) == StepStatus::Ok); + CHECK_DECISION(h, Acquire, HighestScore, 1); + + // Leads by 0.0625: not enough, with or without dwell. B1 would already switch. + CHECK(pair(0.625f, 0.6875f, 100) == StepStatus::Ok); + CHECK_DECISION(h, Keep, InsufficientAdvantage, 1); + CHECK(StatelessSelection(h.trace) == 2); + CHECK(h.trace.challenger == 2 && h.trace.challengerScore == 0.6875f); + CHECK(h.trace.currentEligible && h.trace.currentScore == 0.625f); + CHECK(h.trace.dwellRemainingMs == 400); + + // Leads by exactly switchMargin from 200 ms: dwell defers the switch to 500 ms. + for (const TimeMs now : {TimeMs{200}, TimeMs{300}, TimeMs{499}}) { + CHECK(pair(0.625f, 0.75f, now) == StepStatus::Ok); + CHECK_DECISION(h, Keep, MinimumDwell, 1); + CHECK(h.trace.dwellRemainingMs == 500 - now); + } + CHECK(pair(0.625f, 0.75f, 500) == StepStatus::Ok); + CHECK_DECISION(h, Switch, BetterCandidate, 2); + CHECK(h.output.previous == 1 && h.output.rawScore == 0.75f); + CHECK(h.trace.dwellRemainingMs == 0 && h.memory.focusStartTime == 500); + + // The former target now needs the same margin to come back. + CHECK(pair(0.8125f, 0.75f, 1100) == StepStatus::Ok); + CHECK_DECISION(h, Keep, InsufficientAdvantage, 2); + CHECK(pair(0.875f, 0.75f, 1200) == StepStatus::Ok); + CHECK_DECISION(h, Switch, BetterCandidate, 1); + + // One float step below the margin, the challenger never takes over. + Harness below; + CHECK(below.Run(Observer(), alone, profile, 0) == StepStatus::Ok); + const CandidateSnapshot almost[]{Scored(1, 0.625f), Scored(2, std::nextafter(0.75f, 0.f))}; + bool held = true; + for (int step = 1; step <= 30; ++step) { + held = held && below.Run(Observer(), almost, profile, At(step)) == StepStatus::Ok && + below.output.kind == DecisionKind::Keep && + below.output.reason == DecisionReason::InsufficientAdvantage; + } + CHECK(held); + + // A challenger leading by the margin but below acquireThreshold cannot replace a + // target held in the release band, where B1 would select nothing. + AttentionProfile smallMargin = profile; + smallMargin.switchMargin = 0.0625f; + Harness band; + const CandidateSnapshot atAcquire[]{Scored(1, 0.5f)}; + CHECK(band.Run(Observer(), atAcquire, smallMargin, 0) == StepStatus::Ok); + CHECK_DECISION(band, Acquire, HighestScore, 1); + const CandidateSnapshot inBand[]{Scored(1, 0.375f), Scored(2, 0.46875f)}; + CHECK(band.Run(Observer(), inBand, smallMargin, 1000) == StepStatus::Ok); + CHECK_DECISION(band, Keep, InsufficientAdvantage, 1); + CHECK(StatelessSelection(band.trace) == 0); +} + +// C11a: a challenger leading by overrideMargin and reaching acquireThreshold replaces the +// target within one step, even during dwell. +void C11a_StrongChallengerDuringDwell() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = TemporalProfile(contexts); + const CandidateSnapshot alone[]{Scored(1, 0.625f)}; + const CandidateSnapshot strong[]{Scored(1, 0.625f), Scored(2, 0.875f)}; // leads by 0.25 + + Harness h; + CHECK(h.Run(Observer(), alone, profile, 0) == StepStatus::Ok); + CHECK(h.Run(Observer(), strong, profile, 100) == StepStatus::Ok); + CHECK_DECISION(h, Switch, DwellOverride, 2); + CHECK(h.trace.dwellRemainingMs == 400 && h.memory.focusStartTime == 100); + + // The new target has its own dwell: a lead of only switchMargin waits for it. + const CandidateSnapshot third[]{Scored(1, 0.625f), Scored(2, 0.875f), Scored(3, 1.f)}; + CHECK(h.Run(Observer(), third, profile, 200) == StepStatus::Ok); + CHECK_DECISION(h, Keep, MinimumDwell, 2); + CHECK(h.trace.dwellRemainingMs == 400); + CHECK(h.Run(Observer(), third, profile, 600) == StepStatus::Ok); + CHECK_DECISION(h, Switch, BetterCandidate, 3); + + // One float step below overrideMargin, the switch waits for dwell to end. + Harness below; + CHECK(below.Run(Observer(), alone, profile, 0) == StepStatus::Ok); + const CandidateSnapshot almost[]{Scored(1, 0.625f), Scored(2, std::nextafter(0.875f, 0.f))}; + CHECK(below.Run(Observer(), almost, profile, 100) == StepStatus::Ok); + CHECK_DECISION(below, Keep, MinimumDwell, 1); + CHECK(below.Run(Observer(), almost, profile, 500) == StepStatus::Ok); + CHECK_DECISION(below, Switch, BetterCandidate, 2); + + // Ablation: overrideMargin = 1 cannot be met against a target scoring 0.625, so the + // same strong challenger waits 400 ms for dwell to end instead of switching at once. + AttentionProfile noOverride = profile; + noOverride.overrideMargin = 1.f; + Harness ablated; + CHECK(ablated.Run(Observer(), alone, noOverride, 0) == StepStatus::Ok); + bool waited = true; + for (int step = 1; step <= 4; ++step) { + waited = waited && ablated.Run(Observer(), strong, noOverride, At(step)) == StepStatus::Ok && + ablated.output.reason == DecisionReason::MinimumDwell; + } + CHECK(waited); + CHECK(ablated.Run(Observer(), strong, noOverride, At(5)) == StepStatus::Ok); + CHECK_DECISION(ablated, Switch, BetterCandidate, 2); + + // overrideMargin = 1 only approximates "no override": a challenger at 1 still + // overrides a valid target that has dropped to 0 during dwell. + Harness edge; + CHECK(edge.Run(Observer(), alone, noOverride, 0) == StepStatus::Ok); + const CandidateSnapshot zeroAndOne[]{Scored(1, 0.f), Scored(2, 1.f)}; + CHECK(edge.Run(Observer(), zeroAndOne, noOverride, 100) == StepStatus::Ok); + CHECK_DECISION(edge, Switch, DwellOverride, 2); +} + +// An exact tie never replaces the current target, whatever the IDs and order: B1 +// prefers the lower ID, the policy keeps its target. +void ExactTiesKeepTheCurrentTarget() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = TemporalProfile(contexts); + Harness h; + const CandidateSnapshot first[]{Scored(5, 0.625f)}; + CHECK(h.Run(Observer(), first, profile, 0) == StepStatus::Ok); + const CandidateSnapshot tied[]{Scored(3, 0.625f), Scored(5, 0.625f)}; + const CandidateSnapshot reversed[]{Scored(5, 0.625f), Scored(3, 0.625f)}; + bool kept = true; + for (int step = 1; step <= 50; ++step) { + const std::span candidates = + step % 2 == 0 ? std::span(tied) + : std::span(reversed); + kept = kept && h.Run(Observer(), candidates, profile, At(step)) == StepStatus::Ok && + h.output.kind == DecisionKind::Keep && h.output.selected == 5 && + h.output.reason == DecisionReason::InsufficientAdvantage && + StatelessSelection(h.trace) == 3; + } + CHECK(kept); + + // The lead is compared as a difference, so even the smallest normal float margin + // separates a tie from a one-ulp lead. (0.625f + margin would round back to 0.625f.) + AttentionProfile tiny = profile; + tiny.switchMargin = std::numeric_limits::min(); + tiny.overrideMargin = tiny.switchMargin; + tiny.minDwellMs = 0; + CHECK(0.625f + tiny.switchMargin == 0.625f); + Harness exact; + CHECK(exact.Run(Observer(), first, tiny, 0) == StepStatus::Ok); + CHECK(exact.Run(Observer(), tied, tiny, 100) == StepStatus::Ok); + CHECK_DECISION(exact, Keep, InsufficientAdvantage, 5); + const CandidateSnapshot ulpAhead[]{Scored(3, std::nextafter(0.625f, 1.f)), Scored(5, 0.625f)}; + CHECK(exact.Run(Observer(), ulpAhead, tiny, 200) == StepStatus::Ok); + CHECK_DECISION(exact, Switch, BetterCandidate, 3); +} + +// Dwell uses only host time, checked at each step: repeated timestamps do not advance +// it, late steps act late, and backward time changes nothing. +void ExplicitTimeSemantics() +{ + const ContextPreference contexts[1]{}; + const AttentionProfile profile = TemporalProfile(contexts); + const CandidateSnapshot alone[]{Scored(1, 0.625f)}; + const CandidateSnapshot challenged[]{Scored(1, 0.625f), Scored(2, 0.75f)}; + + Harness h; + CHECK(h.Run(Observer(), alone, profile, 100) == StepStatus::Ok); + CHECK(h.Run(Observer(), challenged, profile, 100) == StepStatus::Ok); + const ObserverMemory afterFirst = h.memory; + CHECK(afterFirst.focusStartTime == 100 && afterFirst.lastStepTime == 100); + bool frozen = true; + for (int repeat = 0; repeat < 10; ++repeat) { + frozen = frozen && h.Run(Observer(), challenged, profile, 100) == StepStatus::Ok && + h.output.reason == DecisionReason::MinimumDwell && + h.trace.dwellRemainingMs == 500 && test::SameMemory(h.memory, afterFirst); + } + CHECK(frozen); + + CHECK(h.Run(Observer(), challenged, profile, 350) == StepStatus::Ok); + CHECK(h.trace.dwellRemainingMs == 250); + CHECK(h.Run(Observer(), challenged, profile, 599) == StepStatus::Ok); + CHECK_DECISION(h, Keep, MinimumDwell, 1); + CHECK(h.trace.dwellRemainingMs == 1); + // The next step comes late; the switch happens then. Resolution is the step interval. + CHECK(h.Run(Observer(), challenged, profile, 950) == StepStatus::Ok); + CHECK_DECISION(h, Switch, BetterCandidate, 2); + CHECK(h.memory.focusStartTime == 950); + + const ObserverMemory before = h.memory; + CHECK(h.Run(Observer(), alone, profile, 949) == StepStatus::TimeWentBackwards); + CHECK(test::SameMemory(h.memory, before)); + + interest::ResetMemory(h.memory); + CHECK(h.memory.current == 0 && h.memory.focusStartTime == 0 && !h.memory.hasStepped); + + // With no dwell the same challenger switches on its first step, even at the same time. + AttentionProfile noDwell = profile; + noDwell.minDwellMs = 0; + Harness immediate; + CHECK(immediate.Run(Observer(), alone, noDwell, 100) == StepStatus::Ok); + CHECK(immediate.Run(Observer(), challenged, noDwell, 100) == StepStatus::Ok); + CHECK_DECISION(immediate, Switch, BetterCandidate, 2); + CHECK(immediate.trace.dwellRemainingMs == 0); +} + +// Elapsed time is exact across the whole int64 range, without overflow at its limits. +void ExtremeTimestamps() +{ + constexpr TimeMs kMin = std::numeric_limits::min(); + constexpr TimeMs kMax = std::numeric_limits::max(); + const ContextPreference contexts[1]{}; + const AttentionProfile profile = TemporalProfile(contexts); + const CandidateSnapshot alone[]{Scored(1, 0.625f)}; + const CandidateSnapshot challenged[]{Scored(1, 0.625f), Scored(2, 0.75f)}; + + Harness wide; + CHECK(wide.Run(Observer(), alone, profile, kMin) == StepStatus::Ok); + CHECK(wide.memory.focusStartTime == kMin); + CHECK(wide.Run(Observer(), challenged, profile, kMax) == StepStatus::Ok); + CHECK_DECISION(wide, Switch, BetterCandidate, 2); + CHECK(wide.trace.dwellRemainingMs == 0); + + Harness late; + CHECK(late.Run(Observer(), alone, profile, kMax - 100) == StepStatus::Ok); + CHECK(late.Run(Observer(), challenged, profile, kMax) == StepStatus::Ok); + CHECK_DECISION(late, Keep, MinimumDwell, 1); + CHECK(late.trace.dwellRemainingMs == 400); + + // The longest dwell, from the earliest time, ends exactly kMax ms later. + AttentionProfile longest = profile; + longest.minDwellMs = kMax; + Harness forever; + CHECK(forever.Run(Observer(), alone, longest, kMin) == StepStatus::Ok); + CHECK(forever.Run(Observer(), challenged, longest, kMin + 5) == StepStatus::Ok); + CHECK_DECISION(forever, Keep, MinimumDwell, 1); + CHECK(forever.trace.dwellRemainingMs == kMax - 5); + CHECK(forever.Run(Observer(), challenged, longest, -2) == StepStatus::Ok); + CHECK(forever.trace.dwellRemainingMs == 1); + CHECK(forever.Run(Observer(), challenged, longest, -1) == StepStatus::Ok); // kMin + kMax + CHECK_DECISION(forever, Switch, BetterCandidate, 2); +} + +// Memory written by Step never has a focus start after `now`; caller-edited memory can. +// Such a focus start gives no dwell, including the case where the unsigned elapsed time +// would wrap to 1 ms (focus start INT64_MAX, now INT64_MIN, found in an extreme-time regression). +void CallerEditedFutureFocusStart() +{ + constexpr TimeMs kMin = std::numeric_limits::min(); + constexpr TimeMs kMax = std::numeric_limits::max(); + const ContextPreference contexts[1]{}; + const AttentionProfile profile = TemporalProfile(contexts); + const CandidateSnapshot challenged[]{Scored(1, 0.625f), Scored(2, 0.75f)}; + + struct Edited { + TimeMs focusStart; + TimeMs lastStep; + bool hasStepped; + TimeMs now; + TimeMs expectedDwell; + }; + const Edited cases[]{ + {kMax, kMin, true, kMin, 0}, // wrapped difference would be 1 ms + {1001, 0, true, 1000, 0}, // 1 ms in the future + {kMax, 0, false, kMin, 0}, // never stepped, so any time is accepted + {1000, 1000, true, 1000, 500}, // control: a focus start equal to now has full dwell + }; + for (const Edited& c : cases) { + Harness h; + h.memory.current = 1; + h.memory.focusStartTime = c.focusStart; + h.memory.lastStepTime = c.lastStep; + h.memory.hasStepped = c.hasStepped; + CHECK(h.Run(Observer(), challenged, profile, c.now) == StepStatus::Ok); + CHECK(h.trace.dwellRemainingMs == c.expectedDwell); + if (c.expectedDwell == 0) { + CHECK_DECISION(h, Switch, BetterCandidate, 2); + CHECK(h.memory.focusStartTime == c.now); // the switch writes a valid start + } else { + CHECK_DECISION(h, Keep, MinimumDwell, 1); + } + } +} + +// Generated streams under random policy profiles, with ties, dropouts, disabled, +// excluded and duplicated entries, empty steps, and repeated and irregular timestamps. +// A replay, a shuffled candidate order and an untraced run must give the same decision +// and memory at every step; every reason must label a kind it allows, and every reason +// must occur. +void GeneratedStreamsAreDeterministic() +{ + ContextPreference neutral{}; + ContextPreference excluding{}; + excluding.excludedTags = TagMask{1}; + const ContextPreference contexts[2]{neutral, excluding}; + + test::Random rng(0x5EED0005u); + test::Random shuffler(0x5EED0006u); + constexpr int kReasons = 10; + int reasonCounts[kReasons]{}; + int steps = 0; + bool deterministic = true; + bool labeled = true; + for (int stream = 0; stream < 40; ++stream) { + AttentionProfile profile = TemporalProfile(contexts); + profile.acquireThreshold = 0.25f + 0.5f * rng.Unit(); + profile.releaseThreshold = profile.acquireThreshold * (0.5f + 0.5f * rng.Unit()); + profile.switchMargin = 0.01f + 0.2f * rng.Unit(); + profile.overrideMargin = profile.switchMargin + 0.3f * rng.Unit(); + profile.minDwellMs = static_cast(rng.Below(800)); + CHECK(interest::ValidateProfile(profile) == StepStatus::Ok); + + Harness reference; + Harness replay; + Harness shuffled; + ObserverMemory untracedMemory{}; + interest::StepScratch untracedScratch; + interest::Reserve(untracedScratch, 12); + interest::Decision untraced{}; + TimeMs now = 0; + for (int step = 0; step < 400; ++step, ++steps) { + now += rng.Below(4) == 0 ? 0 : static_cast(rng.Below(300)); + const interest::ObserverSnapshot observer = + Observer(static_cast(rng.Below(8) == 0 ? 1 : 0)); + std::vector candidates; + const bool empty = rng.Below(25) == 0; + for (CandidateId id = 1; id <= 6 && !empty; ++id) { + if (rng.Below(5) == 0) { + continue; // absent this step + } + // Scores on a 1/8 grid, so exact ties are common. + CandidateSnapshot c = Scored(id, static_cast(rng.Below(9)) / 8.f); + c.tags = rng.Below(10) == 0 ? TagMask{1} : TagMask{0}; + c.enabled = rng.Below(20) != 0; + candidates.push_back(c); + if (rng.Below(30) == 0) { + candidates.push_back(c); // duplicated id + } + } + std::vector permuted = candidates; + shuffler.Shuffle(permuted); + + if (reference.Run(observer, candidates, profile, now) != StepStatus::Ok || + replay.Run(observer, candidates, profile, now) != StepStatus::Ok || + shuffled.Run(observer, permuted, profile, now) != StepStatus::Ok || + interest::Step(observer, candidates, profile, now, untracedMemory, + untracedScratch, untraced) != StepStatus::Ok) { + deterministic = false; + continue; + } + bool same = test::SameDecision(reference.output, replay.output) && + test::SameMemory(reference.memory, replay.memory) && + test::SamePolicyTrace(reference.trace, replay.trace) && + test::SameDecision(reference.output, shuffled.output) && + test::SameMemory(reference.memory, shuffled.memory) && + test::SamePolicyTrace(reference.trace, shuffled.trace) && + test::SameDecision(reference.output, untraced) && + test::SameMemory(reference.memory, untracedMemory); + for (std::size_t i = 0; i < candidates.size() && same; ++i) { + const interest::CandidateTrace& entry = reference.trace.candidates[i]; + same = test::SameEntry(entry, replay.trace.candidates[i]) && + test::SameEntry(entry, *shuffled.Find(entry.id)); + } + deterministic = deterministic && same; + labeled = labeled && test::ReasonMatchesKind(reference.output.kind, + reference.output.reason); + const auto reason = static_cast(reference.output.reason); + if (reason < kReasons) { + ++reasonCounts[reason]; + } + } + } + CHECK(deterministic); + CHECK(labeled); + std::printf(" generated steps: %d\n ", steps); + for (int reason = 0; reason < kReasons; ++reason) { + std::printf(" %s %d", interest::ToString(static_cast(reason)), + reasonCounts[reason]); + CHECK(reasonCounts[reason] > 0); // the generator reaches every policy path + } + std::printf("\n"); +} + +} // namespace + +int main() +{ + test::Run("C08 near-equal score noise", C08_NearEqualScoreNoise); + test::Run("C09 acquire/release threshold oscillation", + C09_AcquireReleaseThresholdOscillation); + test::Run("C10 current target invalid during dwell", C10_CurrentTargetInvalidDuringDwell); + test::Run("C11 persistent challenger", C11_PersistentChallenger); + test::Run("C11a strong challenger during dwell", C11a_StrongChallengerDuringDwell); + test::Run("exact ties keep the current target", ExactTiesKeepTheCurrentTarget); + test::Run("explicit time semantics", ExplicitTimeSemantics); + test::Run("extreme timestamps", ExtremeTimestamps); + test::Run("caller-edited future focus start", CallerEditedFutureFocusStart); + test::Run("generated streams are deterministic", GeneratedStreamsAreDeterministic); + return test::Finish("interest_temporal_test"); +} diff --git a/tests/test_support.hpp b/tests/test_support.hpp new file mode 100644 index 0000000..5491be6 --- /dev/null +++ b/tests/test_support.hpp @@ -0,0 +1,370 @@ +// SPDX-FileCopyrightText: 2026 PullsarDev +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// Minimal dependency-free test support shared by the kernel test executables: +// failure-counting checks, named test cases and small snapshot builders. + +#include "interest/attention.hpp" + +#include +#include +#include +#include +#include + +namespace test { + +struct Counters { + int failures = 0; + int cases = 0; + const char* currentCase = ""; +}; + +inline Counters& State() +{ + static Counters counters; + return counters; +} + +inline void Report(bool ok, const char* expression, const char* file, int line) +{ + if (!ok) { + std::fprintf(stderr, "FAILED [%s] %s:%d: %s\n", State().currentCase, file, line, + expression); + ++State().failures; + } +} + +inline void Run(const char* name, void (*testCase)()) +{ + State().currentCase = name; + ++State().cases; + const int before = State().failures; + testCase(); + std::printf("%s %s\n", State().failures == before ? "PASS" : "FAIL", name); +} + +inline int Finish(const char* suite) +{ + std::printf("%s: %d case(s), %d failed check(s)\n", suite, State().cases, + State().failures); + return State().failures == 0 ? 0 : 1; +} + +inline bool Near(double a, double b, double tolerance) +{ + return std::fabs(a - b) <= tolerance; +} + +inline bool Near(float a, double b, double tolerance) +{ + return Near(static_cast(a), b, tolerance); +} + +// Deterministic test generator (SplitMix64), after Sebastiano Vigna (2015). +// See THIRD_PARTY_NOTICES.md for the public-domain dedication and permission notice. +class Random { +public: + explicit Random(std::uint64_t seed) : state_(seed) {} + + std::uint64_t Next() + { + std::uint64_t z = (state_ += 0x9E3779B97F4A7C15ull); + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBull; + return z ^ (z >> 31); + } + + // Uniform in [0, bound). + std::uint64_t Below(std::uint64_t bound) { return Next() % bound; } + + // Uniform in [0, 1] on a 1/1024 grid, so values are exactly representable. + float Unit() { return static_cast(Below(1025)) / 1024.f; } + + template + void Shuffle(std::vector& values) + { + for (std::size_t i = values.size(); i > 1; --i) { + const auto j = static_cast(Below(i)); + T temp = values[i - 1]; + values[i - 1] = values[j]; + values[j] = temp; + } + } + +private: + std::uint64_t state_; +}; + +// Caller-owned kernel state for one observer, as a host would keep it. Run() prepares +// capacity for the submitted candidates first, as the host's setup code would. +struct Harness { + interest::ObserverMemory memory{}; + interest::StepScratch scratch{}; + interest::Decision output{}; + interest::DecisionTrace trace{}; + + interest::StepStatus Run(const interest::ObserverSnapshot& observer, + std::span candidates, + const interest::AttentionProfile& profile, interest::TimeMs now) + { + interest::Reserve(scratch, candidates.size()); + interest::Reserve(trace, candidates.size()); + return interest::Step(observer, candidates, profile, now, memory, scratch, output, + &trace); + } + + const interest::CandidateTrace* Find(interest::CandidateId id) const + { + for (const interest::CandidateTrace& entry : trace.candidates) { + if (entry.id == id) { + return &entry; + } + } + return nullptr; + } +}; + +inline interest::ObserverSnapshot Observer(interest::ContextId context = 0) +{ + interest::ObserverSnapshot observer{}; + observer.id = 1; + observer.referenceForward = interest::Vec3f{1.f, 0.f, 0.f}; + observer.context = context; + return observer; +} + +// fullRange 10, maxRange 20, angle floor 0.2, equal weights, acquire at 0.3. +// The temporal policy is reduced to its minimum for the scoring scenarios: no release +// band, no dwell, and a 0.001 margin, below their smallest deliberate score difference. +// The temporal tests configure their own policy. +inline interest::AttentionProfile Profile(std::span contexts) +{ + interest::AttentionProfile profile{}; + profile.fullRange = 10.f; + profile.maxRange = 20.f; + profile.angleFloor = 0.2f; + profile.angleSharpness = 1.f; + profile.acquireThreshold = 0.3f; + profile.releaseThreshold = 0.3f; + profile.switchMargin = 0.001f; + profile.overrideMargin = 0.001f; + profile.minDwellMs = 0; + profile.contexts = contexts; + return profile; +} + +// Temporal-policy profile: acquire 0.5, release 0.375, switch margin 0.125, override +// margin 0.25, dwell 500 ms. Attraction-only weights make a NonSpatial candidate's raw +// score exactly its attraction (see Scored), and the dyadic values make every threshold +// and margin comparison exact. +inline interest::AttentionProfile TemporalProfile( + std::span contexts) +{ + interest::AttentionProfile profile = Profile(contexts); + profile.salienceWeight = 0.f; + profile.affinityWeight = 0.f; + profile.acquireThreshold = 0.5f; + profile.releaseThreshold = 0.375f; + profile.switchMargin = 0.125f; + profile.overrideMargin = 0.25f; + profile.minDwellMs = 500; + return profile; +} + +// Stateless selection of policy scores read from this step's trace. With novelty +// disabled this is B1; otherwise it is the effective-score argmax. +inline interest::CandidateId StatelessSelection(const interest::DecisionTrace& trace) +{ + return trace.leading != interest::kNoCandidate && + trace.leadingScore >= trace.acquireThreshold + ? trace.leading + : interest::kNoCandidate; +} + +inline interest::CandidateSnapshot PointAt(interest::CandidateId id, double x, double y, + float attraction, float salience) +{ + interest::CandidateSnapshot candidate{}; + candidate.id = id; + candidate.spatial = interest::SpatialKind::Point; + candidate.position = interest::Vec3d{x, y, 0.0}; + candidate.attraction = attraction; + candidate.salience = salience; + return candidate; +} + +inline interest::CandidateSnapshot NonSpatial(interest::CandidateId id, float attraction, + float salience) +{ + interest::CandidateSnapshot candidate{}; + candidate.id = id; + candidate.spatial = interest::SpatialKind::NonSpatial; + candidate.attraction = attraction; + candidate.salience = salience; + return candidate; +} + +// Under TemporalProfile, raw score == `score` exactly. +inline interest::CandidateSnapshot Scored(interest::CandidateId id, float score) +{ + return NonSpatial(id, score, 0.f); +} + +inline bool SameDecision(const interest::Decision& a, const interest::Decision& b) +{ + return a.kind == b.kind && a.reason == b.reason && a.selected == b.selected && + a.previous == b.previous && a.rawScore == b.rawScore && + a.effectiveScore == b.effectiveScore; +} + +inline bool SameMemory(const interest::ObserverMemory& a, const interest::ObserverMemory& b) +{ + return a.current == b.current && a.focusStartTime == b.focusStartTime && + a.lastStepTime == b.lastStepTime && a.hasStepped == b.hasStepped; +} + +// The decision kinds each reason may label (see DecisionReason). +inline bool ReasonMatchesKind(interest::DecisionKind kind, interest::DecisionReason reason) +{ + using K = interest::DecisionKind; + using R = interest::DecisionReason; + switch (reason) { + case R::NoEligibleCandidate: + case R::BelowAcquireThreshold: return kind == K::None; + case R::HighestScore: return kind == K::Acquire || kind == K::Keep; + case R::CurrentInvalid: + case R::BelowReleaseThreshold: return kind == K::Switch || kind == K::Clear; + case R::BetterCandidate: + case R::DwellOverride: return kind == K::Switch; + case R::MinimumDwell: + case R::InsufficientAdvantage: + case R::AboveReleaseThreshold: return kind == K::Keep; + } + return false; +} + +// A Keep whose reason says the policy holds a target the effective argmax would not select. +inline bool IsHold(const interest::Decision& decision) +{ + using R = interest::DecisionReason; + return decision.kind == interest::DecisionKind::Keep && + (decision.reason == R::MinimumDwell || decision.reason == R::InsufficientAdvantage || + decision.reason == R::AboveReleaseThreshold); +} + +// Compares the step-level trace fields (not the per-candidate entries). +inline bool SamePolicyTrace(const interest::DecisionTrace& a, const interest::DecisionTrace& b) +{ + return a.leading == b.leading && a.leadingScore == b.leadingScore && + a.currentEligible == b.currentEligible && a.currentScore == b.currentScore && + a.challenger == b.challenger && a.challengerScore == b.challengerScore && + a.dwellRemainingMs == b.dwellRemainingMs && a.acquireThreshold == b.acquireThreshold && + a.releaseThreshold == b.releaseThreshold && a.switchMargin == b.switchMargin && + a.overrideMargin == b.overrideMargin; +} + +inline bool SameEntry(const interest::CandidateTrace& a, const interest::CandidateTrace& b) +{ + // Bitwise-equal floats are expected: the same inputs take the same path. + return a.id == b.id && a.eligibility == b.eligibility && a.distance == b.distance && + a.distanceGate == b.distanceGate && a.angleGate == b.angleGate && + a.gate == b.gate && a.affinity == b.affinity && a.preference == b.preference && + a.rawScore == b.rawScore && + a.effectiveScore == b.effectiveScore && a.eventTimeMs == b.eventTimeMs && + a.eventStrength == b.eventStrength && + a.eventNovelty == b.eventNovelty && a.noveltyContribution == b.noveltyContribution; +} + +// Recomputes the policy inputs from the per-candidate entries and checks the decision +// against B1 and the rules of the policy. True when every rule holds. +inline bool ConsistentStep(const Harness& h, const interest::AttentionProfile& profile) +{ + const interest::Decision& d = h.output; + const interest::DecisionTrace& t = h.trace; + interest::CandidateId leading = 0; + interest::CandidateId challenger = 0; + float leadingScore = 0.f; + float challengerScore = 0.f; + float currentScore = 0.f; + bool currentEligible = false; + for (const interest::CandidateTrace& e : t.candidates) { + if (e.eligibility != interest::Eligibility::Eligible) { + continue; + } + if (leading == 0 || e.effectiveScore > leadingScore || + (e.effectiveScore == leadingScore && e.id < leading)) { + leading = e.id; + leadingScore = e.effectiveScore; + } + if (e.id == d.previous) { + currentEligible = true; + currentScore = e.effectiveScore; + } else if (challenger == 0 || e.effectiveScore > challengerScore || + (e.effectiveScore == challengerScore && e.id < challenger)) { + challenger = e.id; + challengerScore = e.effectiveScore; + } + } + bool ok = leading == t.leading && leadingScore == t.leadingScore && + challenger == t.challenger && challengerScore == t.challengerScore && + currentEligible == t.currentEligible && currentScore == t.currentScore; + ok = ok && ReasonMatchesKind(d.kind, d.reason); + // The temporal policy departs from the effective argmax exactly on these holds. + ok = ok && (d.selected != StatelessSelection(t)) == IsHold(d); + + const double lead = static_cast(d.effectiveScore) - static_cast(currentScore); + switch (d.reason) { + case interest::DecisionReason::MinimumDwell: ok = ok && t.dwellRemainingMs > 0; break; + case interest::DecisionReason::DwellOverride: + ok = ok && t.dwellRemainingMs > 0 && lead >= static_cast(profile.overrideMargin); + break; + case interest::DecisionReason::BetterCandidate: + ok = ok && t.dwellRemainingMs == 0 && lead >= static_cast(profile.switchMargin); + break; + case interest::DecisionReason::BelowReleaseThreshold: + ok = ok && t.dwellRemainingMs == 0 && currentScore < profile.releaseThreshold; + break; + case interest::DecisionReason::CurrentInvalid: + ok = ok && d.previous != 0 && !currentEligible; + break; + default: break; + } + if (d.kind == interest::DecisionKind::Keep) { + // Only an eligible target is kept, and below release only while dwell runs. + ok = ok && currentEligible && d.effectiveScore == currentScore && + (currentScore >= profile.releaseThreshold || + d.reason == interest::DecisionReason::MinimumDwell); + } + if (d.kind == interest::DecisionKind::Acquire || d.kind == interest::DecisionKind::Switch) { + ok = ok && d.effectiveScore >= profile.acquireThreshold; + } + return ok; +} + +inline void ExpectDecision(const interest::Decision& decision, interest::DecisionKind kind, + interest::DecisionReason reason, interest::CandidateId selected, + const char* file, int line) +{ + const bool ok = + decision.kind == kind && decision.reason == reason && decision.selected == selected; + if (!ok) { + std::fprintf(stderr, " got %s/%s selected %llu, expected %s/%s selected %llu\n", + interest::ToString(decision.kind), interest::ToString(decision.reason), + static_cast(decision.selected), + interest::ToString(kind), interest::ToString(reason), + static_cast(selected)); + } + Report(ok, "decision kind/reason/selected", file, line); +} + +} // namespace test + +#define CHECK(condition) ::test::Report((condition), #condition, __FILE__, __LINE__) + +// CHECK_DECISION(harness, Keep, MinimumDwell, 1) +#define CHECK_DECISION(harness, kind, reason, selected) \ + ::test::ExpectDecision((harness).output, ::interest::DecisionKind::kind, \ + ::interest::DecisionReason::reason, (selected), __FILE__, __LINE__)