diff --git a/README.md b/README.md index 3b4de97..ddd029d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,18 @@ animation system, learning model or network service. coverage. API, ABI and tuning compatibility may change before 1.0; there is no serialized-state format or cross-platform bit-exact simulation guarantee. +**Next release candidate: v0.2.0.** Event directionality adds an optional trailing +`float CandidateSnapshot::eventDirectionality{1.f}` and +`float CandidateTrace::noveltyGate` after `eventStrength`. The trace field occupies +existing padding on tested x64 compilers, preserving its 96-byte size and all +pre-existing field offsets. These change public struct layouts; rebuild +consumers with matching headers and library. Existing aggregate initializers may +omit the new snapshot field and retain legacy behavior. The release version stays +0.1.0 until a release is approved. + +Use named fields for trace access; positional `CandidateTrace` aggregate initializers +must account for the inserted field before `eventNovelty`. + ## Engineering characteristics - Standard-library-only production C++20; two implementation files and two headers. @@ -65,12 +77,29 @@ 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 +Optional novelty adds `beta * noveltyGate * 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. +`eventDirectionality` controls novelty's angular gate independently of raw preference +scoring: `1` preserves current gating, `0` makes novelty independent of angle, and +intermediate values interpolate linearly: + +```text +noveltyAngleGate = 1 - eventDirectionality * (1 - angleGate) +noveltyGate = distanceGate * noveltyAngleGate * perceptionStrength +``` + +Distance gating and perception strength still apply. At directionality `1`, scoring +reuses the existing rounded `CandidateTrace::gate` exactly; otherwise it rounds the +new gate once for both scoring and `CandidateTrace::noveltyGate`. Directionality must +be finite and within `[0,1]`, including when event strength or beta is zero; invalid +values reject the candidate as `InvalidEvent` at the existing event-validation stage. +Raw scoring and temporal policy are unchanged. The host still controls perception +strength, event generation and candidate range. + 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 @@ -266,9 +295,10 @@ 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. +- By default spatial gates also attenuate novelty. Lower event directionality can + let behind events contribute novelty without changing their raw preference score. + Distance cutoff and zero-perception rejection still apply; the kernel has no + 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. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index cb9d5f2..825283c 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,122 +1,257 @@ # 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. +This report compares the published v0.1.0 kernel with the proposed v0.2.0 candidate +on existing workloads at default event directionality. It measures production +`Step()` calls, excluding host perception, scene traversal, snapshot gathering and +copying, animation and rendering. Results are observations on one desktop, not a +frame-budget guarantee or a comparison with other attention-selection systems. -## Reproduce +Across all nonempty single-observer configurations, the candidate's measured batch +medians are **0.8–3.8% higher** than v0.1.0. Reference medians are **1.0–2.0% higher**. +These are measured compatibility regressions; no performance improvement is claimed. +Both kernels use 96-byte traces on x64. Process ranges and latency tails below retain +all observations, including a large candidate reference-workload spike. -Build Release as described in the README, then run: +## Revisions and environment -```sh -./build/interest_step_benchmark --runs 5 --cycles 128 --raw build/samples.csv -``` +Measured on 2026-10-24. Both source revisions were built fresh with identical +compiler settings; the benchmark harness is byte-identical between them. -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. +| Item | Configuration | +|---|---| +| Release | `v0.1.0`, commit `f89159b296149b0c0552c132fa2528643976316c` | +| Candidate kernel | `7b35d483ffc4d28c1a3792815a0ab1c4c3282a00` | +| CPU | AMD Ryzen 7 5700X, 8 cores / 16 logical processors | +| Memory / OS | 32 GiB RAM; Windows 11 Pro 10.0.26200 | +| Power / scheduling | AMD Ryzen High Performance; normal priority; logical CPU 2 only, affinity mask `0x4` | +| Compiler | MSVC 19.38.33145, Visual Studio 2022 toolset 14.38.33130, x64 | +| Build tools | CMake 3.31.6-msvc6; Visual Studio 17 2022 generator and MSBuild | +| Flags | `/O2 /Ob2 /DNDEBUG`, warnings as errors; no LTO/IPO or fast-math | +| Background load | Ordinary desktop applications; no concurrent compiler builds or benchmark processes; no frequency lock | -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. +Affinity was applied immediately after launching each process, before the Step +workload measurements. It reduces migration but does not prevent other processes +from sharing the same logical CPU. -## Recorded environment +## Reproduce -The following results were measured on 2026-10-16 using the attention implementation -and benchmark harness in this repository. +Check out each measured revision separately and configure both with the same tools: -| 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 | +```powershell +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DINTEREST_WARNINGS_AS_ERRORS=ON -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF +cmake --build build --config Release --parallel 2 +ctest --test-dir build -C Release --output-on-failure --no-tests=error +``` -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. +Run each executable three times, sequentially, using release/candidate, +candidate/release, release/candidate order. Each invocation uses `--runs 5 --cycles 128`. +For the Windows affinity setting used here, use a distinct output file per process: -## Method and workloads +```powershell +$exe = (Resolve-Path './build/Release/interest_step_benchmark.exe').Path +$process = Start-Process -FilePath $exe -ArgumentList '--runs','5','--cycles','128' ` + -WindowStyle Hidden -PassThru -RedirectStandardOutput 'benchmark.txt' +$process.ProcessorAffinity = [IntPtr]0x4 +$process.PriorityClass = [System.Diagnostics.ProcessPriorityClass]::Normal +$process.WaitForExit() +``` + +Optional `--raw samples.csv` writes `section,config,run,index,ns` samples. `--smoke` +checks harness invariants with tiny counts; its timings carry no performance claim. +Debug and sanitizer builds are correctness checks. Compare matching configurations +and inspect process spread and tails. Record any different affinity, background +load, power policy or frequency controls when reproducing these measurements. + +## Workloads and statistics -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. +All candidate inputs use default `eventDirectionality == 1`. This compares +compatibility overhead on existing workloads; timings for nondefault directionality +are not characterized. Event directionality modifies novelty, not raw preference +scoring. The host still controls perception strength, event generation and candidate +range; distance cutoff and zero-perception rejection remain effective. 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. +100/500-candidate front, surround, far80, reject50, shuffle and churn workloads. +Mixed inputs use one-third Point/Bearing/NonSpatial, 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 and a 1,000 ms half-life. See +[`step_benchmark.cpp`](../benchmarks/step_benchmark.cpp) for all inputs. + +Inputs and sample/workspace buffers are prepared before timing. Each process runs +five repetitions of a deterministic 64-frame sequence at explicit 5 Hz, with 128 +cycles per repetition and mode: 40,960 per-call samples per configuration. Every +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. +Each process reports the median of five batch run means. + +The reference workload has 100 observers × 500 candidates × 5 Hz: 500 Steps and +250,000 submitted candidate evaluations per simulated second, on one sequential +worker. Each process measures 50 synchronous 100-observer ticks per run, five runs. +Its world is 70% moving Point, 15% Bearing and 15% NonSpatial, with 56.5% of submitted +entries eligible. Gathering and copying remain outside the timer. Reference values +are kernel work per simulated second, not frame times. + +The tables report median [minimum–maximum] across three independent processes per +revision. Single-observer values summarize the three process batch medians; +reference values summarize the three aggregate kernel times per simulated second. +Change is `100 * (candidate median / release median - 1)`: positive means slower in +these samples. Ranges describe observed variability, not confidence intervals. + +## Direct comparison: full 44-configuration matrix + +Batch nanoseconds per Step, median [process range]: + +| Configuration | Candidates | v0.1.0 | Final candidate | Change | +|---|---:|---:|---:|---:| +| churn/core | 100 | 7849.1 [7812.5–8416.1] | 7985.4 [7920.7–8001.4] | +1.7% | +| churn/core | 500 | 45896.4 [45658.1–46024.1] | 46351.5 [46169.4–46528.0] | +1.0% | +| churn/core+trace | 100 | 8088.4 [8032.8–8262.0] | 8167.8 [8151.8–8254.0] | +1.0% | +| churn/core+trace | 500 | 46883.9 [46537.9–47017.1] | 47358.6 [47128.6–47512.5] | +1.0% | +| far80/core | 100 | 4107.1 [4092.6–4501.8] | 4191.8 [4189.2–4251.6] | +2.1% | +| far80/core | 500 | 22576.7 [22506.2–22740.4] | 23200.9 [23069.0–23438.9] | +2.8% | +| far80/core+trace | 100 | 4616.2 [4602.1–4639.1] | 4707.6 [4693.9–4790.7] | +2.0% | +| far80/core+trace | 500 | 25078.6 [25008.3–25106.5] | 25443.1 [25361.6–25729.6] | +1.5% | +| front/core | 100 | 6946.9 [6927.8–6953.9] | 7157.5 [7142.1–7217.0] | +3.0% | +| front/core | 500 | 36231.6 [35852.4–37593.8] | 36806.2 [36769.4–37233.7] | +1.6% | +| front/core+trace | 100 | 7259.5 [7253.9–7263.2] | 7480.8 [7474.0–7557.6] | +3.0% | +| front/core+trace | 500 | 37388.1 [37381.9–37510.6] | 38442.5 [38338.0–38804.6] | +2.8% | +| mixed/core | 0 | 292.1 [291.3–295.3] | 296.5 [291.3–296.8] | +1.5% | +| mixed/core | 16 | 1175.2 [1175.1–1181.9] | 1216.2 [1211.9–1221.9] | +3.5% | +| mixed/core | 100 | 6140.6 [6135.2–6155.2] | 6371.2 [6339.0–6404.2] | +3.8% | +| mixed/core | 500 | 32002.7 [31929.5–32060.8] | 33193.9 [33038.9–33389.5] | +3.7% | +| mixed/core+trace | 0 | 293.6 [290.4–295.7] | 292.8 [291.9–293.4] | -0.3% | +| mixed/core+trace | 16 | 1230.3 [1229.8–1236.3] | 1277.3 [1266.8–1279.2] | +3.8% | +| mixed/core+trace | 100 | 6482.0 [6482.0–6486.0] | 6716.3 [6687.1–6784.3] | +3.6% | +| mixed/core+trace | 500 | 33594.7 [33573.2–34068.5] | 34845.7 [34647.7–35084.7] | +3.7% | +| mixed/events,beta0 | 0 | 291.5 [290.1–296.8] | 291.5 [289.9–293.0] | 0.0% | +| mixed/events,beta0 | 16 | 1187.9 [1183.7–1188.0] | 1222.6 [1218.9–1228.0] | +2.9% | +| mixed/events,beta0 | 100 | 6239.3 [6236.7–6240.8] | 6462.7 [6414.8–6485.1] | +3.6% | +| mixed/events,beta0 | 500 | 32408.5 [32402.6–33047.1] | 33456.3 [33249.0–33671.3] | +3.2% | +| mixed/novelty | 0 | 293.3 [293.1–293.5] | 291.9 [291.5–293.6] | -0.5% | +| mixed/novelty | 16 | 1198.8 [1195.0–1201.5] | 1237.0 [1230.4–1239.4] | +3.2% | +| mixed/novelty | 100 | 6239.1 [6236.8–6258.4] | 6454.0 [6431.1–6539.5] | +3.4% | +| mixed/novelty | 500 | 32519.7 [32380.5–33208.9] | 33736.7 [33335.7–34355.4] | +3.7% | +| mixed/novelty+trace | 0 | 294.2 [293.1–294.2] | 292.9 [292.4–293.4] | -0.4% | +| mixed/novelty+trace | 16 | 1259.3 [1247.1–1261.1] | 1284.7 [1280.8–1287.6] | +2.0% | +| mixed/novelty+trace | 100 | 6575.6 [6572.1–6583.2] | 6799.1 [6796.2–6868.1] | +3.4% | +| mixed/novelty+trace | 500 | 33987.7 [33983.6–34229.2] | 35201.0 [35066.5–35563.0] | +3.6% | +| reject50/core | 100 | 4709.3 [4707.1–4869.3] | 4832.7 [4793.3–4861.6] | +2.6% | +| reject50/core | 500 | 24252.9 [24215.5–24298.2] | 24872.4 [24716.0–25050.0] | +2.6% | +| reject50/core+trace | 100 | 5216.3 [5195.2–5247.2] | 5369.1 [5260.9–5384.1] | +2.9% | +| reject50/core+trace | 500 | 26554.1 [26526.7–26564.3] | 27193.6 [27066.8–27468.9] | +2.4% | +| shuffle/core | 100 | 7666.1 [7662.6–7678.4] | 7769.1 [7727.3–7861.0] | +1.3% | +| shuffle/core | 500 | 44985.4 [44855.5–45018.1] | 45337.4 [45104.6–45670.5] | +0.8% | +| shuffle/core+trace | 100 | 7898.2 [7896.9–7930.6] | 8046.8 [7992.5–8139.5] | +1.9% | +| shuffle/core+trace | 500 | 45941.3 [45926.0–46373.5] | 46395.9 [46270.9–46659.5] | +1.0% | +| surround/core | 100 | 6942.0 [6928.8–6970.8] | 7149.8 [7146.6–7225.3] | +3.0% | +| surround/core | 500 | 35833.4 [35816.4–35842.0] | 37034.3 [36895.4–37455.4] | +3.4% | +| surround/core+trace | 100 | 7254.1 [7244.7–7277.9] | 7469.0 [7468.3–7578.7] | +3.0% | +| surround/core+trace | 500 | 37548.8 [37464.5–40708.5] | 38532.5 [38400.1–39045.7] | +2.6% | + + +## Reference variants + +Kernel milliseconds per simulated second, median [process range]: + +| Configuration | v0.1.0 | Final candidate | Change | +|---|---:|---:|---:| +| ref/core | 21.176 [21.085–21.358] | 21.394 [21.357–29.844] | +1.0% | +| ref/core,shared-order | 14.084 [14.082–14.174] | 14.362 [14.346–16.420] | +2.0% | +| ref/novelty | 21.649 [21.585–22.427] | 22.085 [21.861–23.976] | +2.0% | +| ref/novelty+trace | 22.171 [22.142–22.382] | 22.473 [22.417–24.770] | +1.4% | + + +## Latency and variability + +Per-call p99 ranges across the three processes, microseconds, at 500 mixed candidates: + +| Configuration | v0.1.0 p99 range | Candidate p99 range | +|---|---:|---:| +| mixed/core | 38.8–50.5 | 39.1–50.8 | +| mixed/core+trace | 40.5–51.5 | 39.0–48.7 | +| mixed/novelty | 39.1–50.4 | 37.2–51.2 | +| mixed/novelty+trace | 44.6–51.8 | 44.9–56.0 | + + +Reference synchronous 100-observer tick p99 ranges across processes, milliseconds: + +| Configuration | v0.1.0 tick p99 range | Candidate tick p99 range | +|---|---:|---:| +| ref/core | 4.329–6.199 | 4.537–18.493 | +| ref/core,shared-order | 3.045–3.399 | 3.087–5.760 | +| ref/novelty | 4.441–6.397 | 4.468–7.127 | +| ref/novelty+trace | 4.618–6.559 | 4.571–7.900 | + + +The observed timer granularity was 100 ns; empty timed regions averaged 18.9–19.1 ns. +Batch measurements are more useful than per-call timers at small candidate counts. +The largest individual single-observer samples were 2.3073 ms for v0.1.0 and +2.5486 ms for the candidate. They are retained as observations, not latency bounds. + +All three candidate reference-core process totals were 21.394 / 29.844 / 21.357 ms +per simulated second; release totals were 21.176 / 21.085 / 21.358 ms. The middle +candidate process also had a synchronous-tick p99 of 18.493 ms. Its samples are +included in the ranges and percentiles. No profiler attribution was performed; +affinity and repeated processes do not eliminate desktop interference. The medians +therefore do not describe worst-case latency or establish a fixed overhead budget. + +## Memory and numerical compatibility + +Both measured kernels have a 96-byte `CandidateTrace` on the tested x64 builds. +In the candidate, `noveltyGate` occupies four bytes of existing alignment padding +after `eventStrength`. All 17 v0.1.0 trace field offsets remain unchanged; no packing +directives are used. Source consumers should rebuild with matching headers/library, +and positional trace aggregate initializers must account for the inserted member. +These are verified layouts, not ABI promises for other architectures. + +| Compiler / target | Trace size | Alignment | eventStrength | noveltyGate | eventNovelty | noveltyContribution | +|---|---:|---:|---:|---:|---:|---:| +| MSVC 19.38.33145 / Windows x64 | 96 | 8 | 72 | 76 | 80 | 88 | +| MSVC 19.51.36260 / Windows x64 | 96 | 8 | 72 | 76 | 80 | 88 | +| GCC 13.3.0 / Linux x86-64 | 96 | 8 | 72 | 76 | 80 | 88 | +| Clang 18.1.3 / Linux x86-64 | 96 | 8 | 72 | 76 | 80 | 88 | + +On MSVC x64, `CandidateSnapshot` is 104 bytes and `ObserverMemory` is 32 bytes in +both revisions. Prepared capacity 500 uses 4,000 bytes of scratch IDs and optionally +48,000 bytes of trace entries, excluding vector objects and allocator overhead. +Only observer memory must persist; one worker can reuse scratch and trace buffers +sequentially. Input arrays belong to the host. Benchmark-process memory also includes +pre-generated input frames and is not kernel retained memory. + +Each of the six Release processes passes the harness's status, paired-input, exact +replay and trace-on/off checks, observing zero allocations in 3,604,480 measured +single-observer calls and 200,000 reference calls per process. The dedicated +allocation regression also reports zero across 404 prepared calls, including +nondefault directionality and rejection paths. `Reserve()` and host setup may +allocate; these observations concern prepared Step calls. + +An independent 4,000-frame × 40-candidate Release replay against v0.1.0 produces +identical binary serialization of every decision, observer-memory field and +pre-existing trace field at default directionality. Padding bytes are excluded. +The default path reuses the original rounded `gate` exactly. The smoke regression +checks the 96-byte size and all 17 legacy offsets on x64; the complete suite also +covers invalid events, validation precedence, tracing, permutations and failure +atomicity. Validation includes MSVC/GCC/Clang Debug and Release, constrained builds, +Release consumers and Clang ASan/UBSan. ## 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. +Measurements cover synthetic kernel workloads on one machine with desktop load. +Affinity does not remove contention, frequency variation or scheduling delays. +Replay checks establish compatibility within a build/platform; cross-platform +bit-exact simulation, ARM and other architectures remain unverified. These timings +do not isolate the cost of a particular validation check or field. Both revisions +already use 96-byte traces, so this comparison does not establish a benefit from +changing trace layout. + +Default angular gating can attenuate behind events. Lower event directionality +reduces that novelty penalty while retaining distance and perception gating. Score +saturation can erase a challenger's unsaturated advantage; margins and dwell can +retain a target after novelty fades. Beta, half-life and update cadence require +host-specific tuning. The scenarios test mechanism behavior, not perceptual realism, +human attention or gameplay quality. diff --git a/include/interest/attention.hpp b/include/interest/attention.hpp index 7078466..3dd6a3c 100644 --- a/include/interest/attention.hpp +++ b/include/interest/attention.hpp @@ -76,6 +76,11 @@ struct CandidateSnapshot { // Resubmit while relevant; omission removes the term. No event history is retained. TimeMs eventTimeMs{}; float eventStrength{}; // finite [0,1], validated even when noveltyBeta == 0 + // Novelty's angular gating only; raw preference scoring is unchanged. + // 1 preserves legacy gating, 0 ignores angle, intermediate values interpolate. + // Finite [0,1], validated even for absent events or noveltyBeta == 0. + // The host still controls perception strength, event generation and candidate range. + float eventDirectionality{1.f}; }; // Per-context eligibility and preference. Selected by ObserverSnapshot::context. @@ -116,7 +121,8 @@ struct AttentionProfile { 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). + // Novelty: effective = min(1, rawScore + beta * noveltyGate * eventNovelty). + // noveltyGate retains distance/perception gating; eventDirectionality controls angle. // 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 @@ -199,7 +205,7 @@ enum class Eligibility : std::uint8_t { MissingRequiredTag, ExcludedTag, OutOfRange, // Point farther than its cutoff distance - InvalidEvent, // non-finite/out-of-range strength or positive-strength future time + InvalidEvent, // invalid strength/directionality or positive-strength future time }; // One evaluated candidate. Factors are the values used for selection; those the @@ -220,8 +226,12 @@ struct CandidateTrace { float effectiveScore{}; // min(1, rawScore + noveltyContribution), as compared TimeMs eventTimeMs{}; // input copy, ignored if eventStrength == 0 float eventStrength{}; // input copy, including rejected values + // Rounded distanceGate * (1 - eventDirectionality * (1 - angleGate)) * perceptionStrength, + // computed from unrounded geometric factors. At directionality 1, reuses gate exactly. + // Placed here to reuse x64 alignment padding without moving pre-existing fields. + float noveltyGate{}; double eventNovelty{}; // strength * 2^(-age / halfLife), [0,1]; absent => 0 - double noveltyContribution{}; // beta * gate * eventNovelty, before saturation + double noveltyContribution{}; // beta * noveltyGate * eventNovelty, before saturation }; // Optional caller-owned explanation of one successful Step; written only when Ok. diff --git a/src/step.cpp b/src/step.cpp index 3732779..e49ac32 100644 --- a/src/step.cpp +++ b/src/step.cpp @@ -164,6 +164,7 @@ void EvaluateCandidate(const CandidateSnapshot& candidate, bool duplicate, return reject(Eligibility::InvalidGeometry); } if (!IsUnitInterval(candidate.eventStrength) || + !IsUnitInterval(candidate.eventDirectionality) || (candidate.eventStrength > 0.f && candidate.eventTimeMs > now)) { return reject(Eligibility::InvalidEvent); } @@ -222,6 +223,15 @@ void EvaluateCandidate(const CandidateSnapshot& candidate, bool duplicate, out.distanceGate = static_cast(distanceGate); out.angleGate = static_cast(angleGate); out.gate = static_cast(gate); + // Reuse the legacy rounded gate exactly at the default. Recomputing from the + // rounded trace factors would change novelty and possibly temporal decisions. + out.noveltyGate = out.gate; + if (candidate.eventDirectionality != 1.f) { + const double noveltyAngleGate = + 1.0 - static_cast(candidate.eventDirectionality) * (1.0 - angleGate); + out.noveltyGate = static_cast( + distanceGate * noveltyAngleGate * static_cast(candidate.perceptionStrength)); + } out.affinity = affinity; out.preference = static_cast(preference); // Selection compares this float, so the trace shows exactly the compared value. @@ -447,7 +457,7 @@ StepStatus Step(const ObserverSnapshot& observer, static_cast(profile.noveltyHalfLifeMs)); } evaluation.noveltyContribution = static_cast(profile.noveltyBeta) * - static_cast(evaluation.gate) * evaluation.eventNovelty; + static_cast(evaluation.noveltyGate) * 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)( diff --git a/tests/smoke_test.cpp b/tests/smoke_test.cpp index 855ed7a..b4d3ee2 100644 --- a/tests/smoke_test.cpp +++ b/tests/smoke_test.cpp @@ -1,12 +1,15 @@ // SPDX-FileCopyrightText: 2026 PullsarDev // SPDX-License-Identifier: Apache-2.0 -// Verifies version metadata, C++20 and optional constrained-build compiler flags. +// Verifies version metadata, x64 trace layout, C++20 and optional compiler constraints. +#include "interest/attention.hpp" #include "interest/version.hpp" +#include #include #include +#include namespace { @@ -20,6 +23,48 @@ void Expect(bool condition, const char* description) } } +void CheckTraceLayout() +{ + using interest::CandidateTrace; + static_assert(std::is_standard_layout_v); + std::printf("CandidateTrace: size=%zu alignment=%zu eventStrength=%zu noveltyGate=%zu " + "eventNovelty=%zu noveltyContribution=%zu\n", sizeof(CandidateTrace), + alignof(CandidateTrace), offsetof(CandidateTrace, eventStrength), + offsetof(CandidateTrace, noveltyGate), offsetof(CandidateTrace, eventNovelty), + offsetof(CandidateTrace, noveltyContribution)); +#if defined(_M_X64) || defined(__x86_64__) + // Recorded v0.1.0 offsets for the tested x64 ABIs. Keep the full legacy field + // list here so using alignment padding cannot silently move an earlier field. + // These checks document the tested layouts, not an ABI promise for other targets. + struct FieldOffset { const char* name; std::size_t actual; std::size_t legacy; }; + const FieldOffset fields[]{ + {"id", offsetof(CandidateTrace, id), 0}, + {"eligibility", offsetof(CandidateTrace, eligibility), 8}, + {"attraction", offsetof(CandidateTrace, attraction), 12}, + {"salience", offsetof(CandidateTrace, salience), 16}, + {"perceptionStrength", offsetof(CandidateTrace, perceptionStrength), 20}, + {"distance", offsetof(CandidateTrace, distance), 24}, + {"distanceGate", offsetof(CandidateTrace, distanceGate), 32}, + {"angleGate", offsetof(CandidateTrace, angleGate), 36}, + {"gate", offsetof(CandidateTrace, gate), 40}, + {"affinity", offsetof(CandidateTrace, affinity), 44}, + {"preference", offsetof(CandidateTrace, preference), 48}, + {"rawScore", offsetof(CandidateTrace, rawScore), 52}, + {"effectiveScore", offsetof(CandidateTrace, effectiveScore), 56}, + {"eventTimeMs", offsetof(CandidateTrace, eventTimeMs), 64}, + {"eventStrength", offsetof(CandidateTrace, eventStrength), 72}, + {"eventNovelty", offsetof(CandidateTrace, eventNovelty), 80}, + {"noveltyContribution", offsetof(CandidateTrace, noveltyContribution), 88}, + }; + Expect(sizeof(CandidateTrace) == 96, "x64 trace retains the v0.1.0 96-byte size"); + Expect(alignof(CandidateTrace) == 8, "x64 trace retains 8-byte alignment"); + Expect(offsetof(CandidateTrace, noveltyGate) == 76, "noveltyGate occupies x64 alignment padding"); + for (const auto& field : fields) { + Expect(field.actual == field.legacy, field.name); + } +#endif +} + } // namespace int main() @@ -28,6 +73,7 @@ int main() "VersionString() returns the library version"); Expect(__cplusplus >= 202002L, "compiled as C++20 or later"); + CheckTraceLayout(); #if defined(INTEREST_EXPECT_CONSTRAINED_BUILD) #if defined(__cpp_exceptions) || defined(_CPPUNWIND) diff --git a/tests/step_allocation_test.cpp b/tests/step_allocation_test.cpp index c4e01c3..5a5b66e 100644 --- a/tests/step_allocation_test.cpp +++ b/tests/step_allocation_test.cpp @@ -79,6 +79,7 @@ void PreparedStepsDoNotAllocate() c.enabled = rng.Below(8) != 0; c.eventTimeMs = -100; c.eventStrength = 0.75f; + c.eventDirectionality = static_cast(rng.Below(5)) / 4.f; } } // Repeated population changes with eligible inputs and fresh events, independent @@ -89,6 +90,7 @@ void PreparedStepsDoNotAllocate() 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; + frames[i].back().eventDirectionality = static_cast(j % 5) / 4.f; } } const std::vector oversized(kCapacity + 1, test::NonSpatial(1, 1.f, 1.f)); diff --git a/tests/step_c18_test.cpp b/tests/step_c18_test.cpp index 5de5bfe..c3cb61e 100644 --- a/tests/step_c18_test.cpp +++ b/tests/step_c18_test.cpp @@ -47,7 +47,7 @@ bool SameEntry(const CandidateTrace& a, const CandidateTrace& b) 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; + a.noveltyContribution == b.noveltyContribution && a.noveltyGate == b.noveltyGate; return same; } diff --git a/tests/step_novelty_test.cpp b/tests/step_novelty_test.cpp index 7bd9dd4..8852ca5 100644 --- a/tests/step_novelty_test.cpp +++ b/tests/step_novelty_test.cpp @@ -60,7 +60,7 @@ void CheckStep(const Harness& h, const AttentionProfile& p, TimeMs now) } CHECK(e.eventNovelty == novelty && novelty >= 0 && novelty <= 1); CHECK(e.noveltyContribution == static_cast(p.noveltyBeta) * - static_cast(e.gate) * novelty); + static_cast(e.noveltyGate) * novelty); CHECK(e.effectiveScore == static_cast((std::min)( 1.0, static_cast(e.rawScore) + e.noveltyContribution))); if (e.id == h.output.selected) { @@ -338,7 +338,8 @@ void ErrorsPreserveAllBuffers() { const ContextPreference contexts[1]{}; const auto p = NoveltyProfile(contexts); - const CandidateSnapshot frame[]{Event(Scored(1, 0.4f), 0)}; + CandidateSnapshot frame[]{Event(Scored(1, 0.4f), 0)}; + frame[0].eventDirectionality = 0.5f; Harness h; CHECK(h.Run(Observer(), frame, p, 1000) == StepStatus::Ok); const Harness before = h; @@ -403,6 +404,7 @@ void ReplayPermutationsAndAblation() 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.eventDirectionality = static_cast(step % 5) / 4.f; c.enabled = rng.Below(20) != 0; frame.push_back(c); } @@ -411,6 +413,10 @@ void ReplayPermutationsAndAblation() 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)); + CHECK(test::SamePolicyTrace(h.trace, replay.trace)); + for (std::size_t i = 0; i < frame.size(); ++i) { + CHECK(test::SameEntry(h.trace.candidates[i], replay.trace.candidates[i])); + } CheckStep(h, p, now); interest::Reserve(untraced.scratch, frame.size()); CHECK(interest::Step(Observer(), frame, p, now, untraced.memory, untraced.scratch, @@ -437,6 +443,204 @@ void ReplayPermutationsAndAblation() "B2 decision/memory ablation identical\n"); } +void DirectionalityGatesOnlyNovelty() +{ + const ContextPreference contexts[1]{}; + auto p = NoveltyProfile(contexts); + p.angleFloor = 0.25f; + // Both geometry kinds with angular gates, plus the NonSpatial identity case. + for (auto kind : {interest::SpatialKind::Point, interest::SpatialKind::Bearing, + interest::SpatialKind::NonSpatial}) { + for (double sign : {-1.0, 1.0}) { + float previousGate = 1.f; + float previousRaw = -1.f; + for (float d : {0.f, 0.25f, 0.5f, 0.75f, 1.f}) { + auto c = Event(Scored(1, 0.25f), 0); + c.spatial = kind; + c.position = {15.0 * sign, 0, 0}; // distance falloff .5 + c.bearing = {static_cast(sign), 0, 0}; + c.perceptionStrength = 0.5f; + c.eventDirectionality = d; + Harness h; + CHECK(h.Run(Observer(), std::span(&c, 1), p, 1000) == StepStatus::Ok); + const auto& e = *h.Find(1); + const double distance = kind == interest::SpatialKind::Point ? 0.5 : 1.0; + const double angle = kind == interest::SpatialKind::NonSpatial || sign > 0 ? 1.0 : 0.25; + CHECK(static_cast(e.distanceGate) == distance && + static_cast(e.angleGate) == angle); + CHECK(static_cast(e.noveltyGate) == + distance * (1.0 - static_cast(d) * (1.0 - angle)) * 0.5); + CHECK(static_cast(e.gate) == distance * angle * 0.5); + CHECK(static_cast(e.rawScore) == distance * angle * 0.5 * 0.25); + CHECK(e.noveltyGate <= previousGate); + CHECK(previousRaw < 0 || e.rawScore == previousRaw); + CHECK(e.eventNovelty == 0.5); + if (d == 1.f) { CHECK(e.noveltyGate == e.gate); } + previousGate = e.noveltyGate; + previousRaw = e.rawScore; + CheckStep(h, p, 1000); + } + } + } + for (float d : {0.f, 0.5f, 1.f}) { + for (double distance : {20.0, std::nextafter(20.0, 21.0), 21.0}) { + auto c = Event(test::PointAt(1, -distance, 0, 1.f, 1.f), 0); + c.eventDirectionality = d; + Harness h; + CHECK(h.Run(Observer(), std::span(&c, 1), p, 0) == StepStatus::Ok); + CHECK(h.Find(1)->eligibility == (distance > 20 ? Eligibility::OutOfRange : Eligibility::Eligible)); + CHECK(h.Find(1)->noveltyGate == 0 && h.Find(1)->effectiveScore == 0); + c.maxDistance = 10.f; + CHECK(h.Run(Observer(), std::span(&c, 1), p, 0) == StepStatus::Ok); + CHECK(h.Find(1)->eligibility == Eligibility::OutOfRange); + c.position = {-5, 0, 0}; + c.perceptionStrength = 0; + CHECK(h.Run(Observer(), std::span(&c, 1), p, 0) == StepStatus::Ok); + CHECK(h.Find(1)->eligibility == Eligibility::NotPerceived); + CHECK(h.Find(1)->noveltyGate == 0 && h.Find(1)->effectiveScore == 0); + } + } +} + +void DefaultPreservesLegacyRoundedGate() +{ + const ContextPreference contexts[1]{}; + auto p = NoveltyProfile(contexts); + auto off = p; off.noveltyBeta = 0; + test::Random rng(0xD120010); + int differentRounding = 0; + for (int i = 0; i < 1000; ++i) { + auto c = Event(test::PointAt(1, 10.0 + 8.0 * static_cast(rng.Unit()), + static_cast(rng.Unit()), + rng.Unit(), rng.Unit()), 0, rng.Unit()); + c.perceptionStrength = 0.1f + 0.9f * rng.Unit(); + CHECK(c.eventDirectionality == 1.f); + Harness h, base, explicitDefault; + CHECK(base.Run(Observer(), std::span(&c, 1), off, 1000) == StepStatus::Ok); + CHECK(h.Run(Observer(), std::span(&c, 1), p, 1000) == StepStatus::Ok); + const auto& e = *h.Find(1); + auto expected = *base.Find(1); + // Legacy composition uses this rounded gate, never a product of trace factors. + expected.noveltyContribution = static_cast(p.noveltyBeta) * + static_cast(expected.gate) * expected.eventNovelty; + expected.effectiveScore = static_cast((std::min)( + 1.0, static_cast(expected.rawScore) + expected.noveltyContribution)); + CHECK(test::SameEntry(e, expected)); + CHECK(e.attraction == expected.attraction && e.salience == expected.salience && + e.perceptionStrength == expected.perceptionStrength); + CHECK(e.noveltyGate == e.gate); + const float recombined = static_cast(static_cast(e.distanceGate) * + static_cast(e.angleGate) * static_cast(e.perceptionStrength)); + differentRounding += recombined != e.gate ? 1 : 0; + c.eventDirectionality = 1.f; + CHECK(explicitDefault.Run(Observer(), std::span(&c, 1), p, 1000) == StepStatus::Ok); + CHECK(test::SameEntry(e, *explicitDefault.Find(1))); + CHECK(test::SameDecision(h.output, explicitDefault.output)); + CHECK(test::SamePolicyTrace(h.trace, explicitDefault.trace)); + CHECK(test::SameMemory(h.memory, explicitDefault.memory)); + CheckStep(h, p, 1000); + } + CHECK(differentRounding > 100); // this fixture detects a rounded-factor recomputation +} + +void InvalidDirectionalityAndPrecedence() +{ + ContextPreference contexts[1]{}; + auto p = NoveltyProfile(contexts); + p.minDwellMs = 10000; + const float bad[]{-0.01f, 1.01f, -std::numeric_limits::infinity(), + std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}; + for (float beta : {0.f, 0.75f}) { + p.noveltyBeta = beta; + for (float strength : {0.f, 1.f}) { + for (float d : bad) { + Harness h; + const CandidateSnapshot first[]{Scored(1, 0.8f)}; + CHECK(h.Run(Observer(), first, p, 0) == StepStatus::Ok); + CandidateSnapshot frame[]{Event(first[0], 0, strength), Scored(2, 0.625f)}; + frame[0].eventDirectionality = d; + CHECK(h.Run(Observer(), frame, p, 100) == StepStatus::Ok); + CHECK(h.Find(1)->eligibility == Eligibility::InvalidEvent); + CHECK_DECISION(h, Switch, CurrentInvalid, 2); + CHECK(h.Find(1)->noveltyGate == 0); + CheckStep(h, p, 100); + } + } + } + // Earlier checks still win; later eligibility failures never hide InvalidEvent. + auto c = Event(test::PointAt(1, 30, 0, 1.f, 1.f), 0); + c.eventDirectionality = -1; + const auto check = [&](const CandidateSnapshot& value, Eligibility expected) { + Harness h; + CHECK(h.Run(Observer(), std::span(&value, 1), p, 0) == StepStatus::Ok); + CHECK(h.trace.candidates[0].eligibility == expected); + }; + check(c, Eligibility::InvalidEvent); + c.enabled = false; check(c, Eligibility::InvalidEvent); + c.perceptionStrength = 0; check(c, Eligibility::InvalidEvent); + contexts[0].requiredTags = 1; check(c, Eligibility::InvalidEvent); + contexts[0].requiredTags = 0; contexts[0].excludedTags = 1; c.tags = 1; + check(c, Eligibility::InvalidEvent); + c.position.x = std::numeric_limits::quiet_NaN(); + check(c, Eligibility::InvalidGeometry); + c.attraction = -1; check(c, Eligibility::InvalidValue); + c.id = 0; check(c, Eligibility::InvalidId); + c.id = 1; + const CandidateSnapshot duplicates[]{c, c}; + Harness h; + CHECK(h.Run(Observer(), duplicates, p, 0) == StepStatus::Ok); + for (const auto& e : h.trace.candidates) { CHECK(e.eligibility == Eligibility::DuplicateId); } +} + +void DirectionalCaptureDecayAndConditionalReturn() +{ + const ContextPreference contexts[1]{}; + auto p = NoveltyProfile(contexts); + p.angleFloor = 0; + p.minDwellMs = 500; + for (float d : {0.f, 0.5f, 1.f}) { + for (float previousScore : {0.625f, 0.25f}) { + Harness h; + const CandidateSnapshot first[]{Scored(1, 0.625f)}; + CHECK(h.Run(Observer(), first, p, 0) == StepStatus::Ok); + auto event = Event(test::PointAt(2, -5, 0, 1.f, 1.f), 100); + event.eventDirectionality = d; + CandidateSnapshot frame[]{Scored(1, previousScore), event}; + CHECK(h.Run(Observer(), frame, p, 100) == StepStatus::Ok); + CHECK(h.Find(2)->rawScore == 0); + CHECK(h.Find(2)->effectiveScore == 0.75f * (1.f - d)); + const bool captured = d == 0.f && previousScore == 0.25f; + CHECK((h.output.selected == 2) == captured); + if (captured) { CHECK_DECISION(h, Switch, DwellOverride, 2); } + CHECK(h.Run(Observer(), frame, p, 1100) == StepStatus::Ok); + CHECK(h.Find(2)->eventNovelty == 0.5); + CHECK(h.Run(Observer(), frame, p, 2100) == StepStatus::Ok); + CHECK(h.Find(2)->eventNovelty == 0.25); + if (captured) { + CHECK_DECISION(h, Clear, BelowReleaseThreshold, 0); + // Return requires the old target to qualify for acquisition again. + frame[0].attraction = 0.625f; + CHECK(h.Run(Observer(), frame, p, 2200) == StepStatus::Ok); + CHECK_DECISION(h, Acquire, HighestScore, 1); + } + CheckStep(h, p, captured ? 2200 : 2100); + } + // A qualifying previous target returns directly after a captured event fades. + Harness h; + const CandidateSnapshot first[]{Scored(1, 0.625f)}; + CHECK(h.Run(Observer(), first, p, 0) == StepStatus::Ok); + auto event = Event(test::PointAt(2, -5, 0, 1.f, 1.f), 500); + event.eventDirectionality = d; + const CandidateSnapshot frame[]{first[0], event}; + CHECK(h.Run(Observer(), frame, p, 500) == StepStatus::Ok); + CHECK((h.output.selected == 2) == (d == 0.f)); + CHECK(h.Run(Observer(), frame, p, 2500) == StepStatus::Ok); + CHECK(h.output.selected == 1); + if (d == 0.f) { CHECK_DECISION(h, Switch, BelowReleaseThreshold, 1); } + CheckStep(h, p, 2500); + } +} + void EnableAndResetPreserveEventAge() { const ContextPreference contexts[1]{}; @@ -462,6 +666,10 @@ void EnableAndResetPreserveEventAge() int main() { + test::Run("directionality gates novelty independently of raw scoring", DirectionalityGatesOnlyNovelty); + test::Run("default preserves legacy rounded gate and all trace fields", DefaultPreservesLegacyRoundedGate); + test::Run("invalid directionality and eligibility precedence", InvalidDirectionalityAndPrecedence); + test::Run("directional capture, decay and conditional return", DirectionalCaptureDecayAndConditionalReturn); 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); diff --git a/tests/test_support.hpp b/tests/test_support.hpp index 5491be6..7e31567 100644 --- a/tests/test_support.hpp +++ b/tests/test_support.hpp @@ -275,7 +275,8 @@ inline bool SameEntry(const interest::CandidateTrace& a, const interest::Candida a.rawScore == b.rawScore && a.effectiveScore == b.effectiveScore && a.eventTimeMs == b.eventTimeMs && a.eventStrength == b.eventStrength && - a.eventNovelty == b.eventNovelty && a.noveltyContribution == b.noveltyContribution; + a.eventNovelty == b.eventNovelty && a.noveltyContribution == b.noveltyContribution && + a.noveltyGate == b.noveltyGate; } // Recomputes the policy inputs from the per-candidate entries and checks the decision