From d73c3b007b107f78bb8b8ef137f62b6c1945f121 Mon Sep 17 00:00:00 2001 From: Pullsar <64874805+PullsarDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:55:26 +0300 Subject: [PATCH 1/6] Add independent angular gating for event novelty --- README.md | 33 +++++- include/interest/attention.hpp | 15 ++- src/step.cpp | 12 +- tests/step_allocation_test.cpp | 2 + tests/step_novelty_test.cpp | 209 ++++++++++++++++++++++++++++++++- tests/test_support.hpp | 3 +- 6 files changed, 263 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3b4de97..3c245fa 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ 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 a trailing +`float CandidateTrace::noveltyGate`. 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. + ## Engineering characteristics - Standard-library-only production C++20; two implementation files and two headers. @@ -65,12 +72,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 +290,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/include/interest/attention.hpp b/include/interest/attention.hpp index 7078466..5ae7d6f 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 @@ -221,7 +227,10 @@ struct CandidateTrace { 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 + double noveltyContribution{}; // beta * noveltyGate * eventNovelty, before saturation + // Rounded distanceGate * (1 - eventDirectionality * (1 - angleGate)) * perceptionStrength, + // computed from unrounded geometric factors. At directionality 1, reuses gate exactly. + float noveltyGate{}; }; // 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/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_novelty_test.cpp b/tests/step_novelty_test.cpp index 7bd9dd4..316a8e7 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,201 @@ 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(e.distanceGate == distance && e.angleGate == angle); + CHECK(e.noveltyGate == distance * (1.0 - d * (1.0 - angle)) * 0.5); + CHECK(e.gate == distance * angle * 0.5); + CHECK(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 * rng.Unit(), 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 +663,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 From 2b8a7b35f0bbc9ec24d35fdc56b902b469485e5d Mon Sep 17 00:00:00 2001 From: Pullsar <64874805+PullsarDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:56:38 +0300 Subject: [PATCH 2/6] Use explicit precision conversions in directionality regressions --- tests/step_novelty_test.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/step_novelty_test.cpp b/tests/step_novelty_test.cpp index 316a8e7..8852ca5 100644 --- a/tests/step_novelty_test.cpp +++ b/tests/step_novelty_test.cpp @@ -466,10 +466,12 @@ void DirectionalityGatesOnlyNovelty() 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(e.distanceGate == distance && e.angleGate == angle); - CHECK(e.noveltyGate == distance * (1.0 - d * (1.0 - angle)) * 0.5); - CHECK(e.gate == distance * angle * 0.5); - CHECK(e.rawScore == distance * angle * 0.5 * 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); @@ -508,7 +510,8 @@ void DefaultPreservesLegacyRoundedGate() test::Random rng(0xD120010); int differentRounding = 0; for (int i = 0; i < 1000; ++i) { - auto c = Event(test::PointAt(1, 10.0 + 8.0 * rng.Unit(), rng.Unit(), + 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); From f6d74120561f133cc41ec6b2499cf0a08eb1ee25 Mon Sep 17 00:00:00 2001 From: Pullsar <64874805+PullsarDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:02:47 +0300 Subject: [PATCH 3/6] Document event directionality benchmark comparison against v0.1.0 --- docs/benchmarks.md | 119 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index cb9d5f2..2908055 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -113,10 +113,123 @@ 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. +At default event directionality, 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. + +## Event directionality candidate (proposed v0.2.0) + +`CandidateSnapshot::eventDirectionality` changes novelty's angular gate, not raw +preference scoring. Its default of 1 reuses the legacy rounded `gate` exactly. +Lower values reduce the novelty angle penalty while keeping distance and perception +gating. The host still controls perception strength, event generation and candidate +range. Capture and return remain conditional on effective scores and temporal policy. + +Both this trailing snapshot field and the new trailing `CandidateTrace::noveltyGate` +change the public layout contract: rebuild consumers against matching headers and +library. On MSVC x64 the snapshot remains 104 bytes (the new float uses tail padding), +while each trace entry grows from 96 to 104 bytes. Capacity 500 therefore uses +52,000 bytes of optional trace storage, up from 48,000; scratch ID storage and +32-byte observer memory are unchanged. These are measured sizes, not ABI promises. + +The comparison on 2026-10-24 rebuilds tag `v0.1.0` (`f89159b`) and the event +directionality implementation (`0f9d1b4`) with the same MSVC 19.38.33145 x64 Release settings, +Visual Studio generator, warnings as errors and no LTO/IPO. CPU, RAM, OS and power +policy match the recorded environment above; background desktop applications were +running, with no affinity, priority or frequency controls. No compiler builds ran +concurrently with the timed processes. + +Each revision runs the unchanged 44-configuration harness in three independent +processes, each with `--runs 5 --cycles 128`. Process order is baseline/candidate, +candidate/baseline, baseline/candidate to expose drift. All inputs retain default +directionality, so these measure compatibility overhead on existing workloads, +not the cost or behavior of nondefault directionality. Single-observer comparisons +use the median of three reported process batch medians; reference comparisons use +the median of the three aggregate kernel times per simulated second. Per-call tails +and all process results are inspected separately; timings are desktop observations. + +Measured batch nanoseconds per Step (default directionality throughout): + +| Configuration | Candidates | v0.1.0 | Candidate | Change | +|---|---:|---:|---:|---:| +| churn/core | 100 | 7828.5 | 7970.6 | +1.8% | +| churn/core | 500 | 45798.2 | 46221.8 | +0.9% | +| churn/core+trace | 100 | 8063.0 | 8232.6 | +2.1% | +| churn/core+trace | 500 | 46743.8 | 47516.9 | +1.7% | +| far80/core | 100 | 4100.5 | 4255.1 | +3.8% | +| far80/core | 500 | 22575.2 | 23296.6 | +3.2% | +| far80/core+trace | 100 | 4617.0 | 4765.0 | +3.2% | +| far80/core+trace | 500 | 24943.8 | 25774.9 | +3.3% | +| front/core | 100 | 6948.0 | 7250.4 | +4.4% | +| front/core | 500 | 35678.9 | 37233.9 | +4.4% | +| front/core+trace | 100 | 7294.1 | 7587.7 | +4.0% | +| front/core+trace | 500 | 37266.3 | 38987.5 | +4.6% | +| mixed/core | 0 | 293.3 | 294.4 | +0.4% | +| mixed/core | 16 | 1180.8 | 1219.3 | +3.3% | +| mixed/core | 100 | 6150.1 | 6422.7 | +4.4% | +| mixed/core | 500 | 32019.7 | 33278.4 | +3.9% | +| mixed/core+trace | 0 | 291.7 | 291.5 | -0.1% | +| mixed/core+trace | 16 | 1231.7 | 1276.7 | +3.7% | +| mixed/core+trace | 100 | 6482.7 | 6773.7 | +4.5% | +| mixed/core+trace | 500 | 33533.5 | 35128.1 | +4.8% | +| mixed/events,beta0 | 0 | 291.8 | 291.3 | -0.2% | +| mixed/events,beta0 | 16 | 1188.4 | 1228.7 | +3.4% | +| mixed/events,beta0 | 100 | 6242.7 | 6489.5 | +4.0% | +| mixed/events,beta0 | 500 | 32287.8 | 33712.6 | +4.4% | +| mixed/novelty | 0 | 292.8 | 291.9 | -0.3% | +| mixed/novelty | 16 | 1197.3 | 1237.4 | +3.3% | +| mixed/novelty | 100 | 6260.2 | 6512.6 | +4.0% | +| mixed/novelty | 500 | 32361.4 | 33710.0 | +4.2% | +| mixed/novelty+trace | 0 | 295.6 | 291.9 | -1.3% | +| mixed/novelty+trace | 16 | 1253.3 | 1292.8 | +3.2% | +| mixed/novelty+trace | 100 | 6574.5 | 6858.9 | +4.3% | +| mixed/novelty+trace | 500 | 33989.5 | 35497.1 | +4.4% | +| reject50/core | 100 | 4711.8 | 4872.8 | +3.4% | +| reject50/core | 500 | 24198.0 | 25080.6 | +3.6% | +| reject50/core+trace | 100 | 5184.9 | 5358.5 | +3.3% | +| reject50/core+trace | 500 | 26547.4 | 27458.3 | +3.4% | +| shuffle/core | 100 | 7659.6 | 7791.6 | +1.7% | +| shuffle/core | 500 | 44783.3 | 45370.5 | +1.3% | +| shuffle/core+trace | 100 | 7884.1 | 8089.7 | +2.6% | +| shuffle/core+trace | 500 | 45897.8 | 46571.8 | +1.5% | +| surround/core | 100 | 6951.0 | 7250.4 | +4.3% | +| surround/core | 500 | 35741.6 | 37329.9 | +4.4% | +| surround/core+trace | 100 | 7275.9 | 7564.2 | +4.0% | +| surround/core+trace | 500 | 37471.1 | 39120.6 | +4.4% | + +Reference kernel milliseconds per simulated second (100 observers × 500 candidates × 5 Hz): + +| Configuration | v0.1.0 | Candidate | Change | +|---|---:|---:|---:| +| ref/core | 21.099 | 21.401 | +1.4% | +| ref/core,shared-order | 14.110 | 14.489 | +2.7% | +| ref/novelty | 21.572 | 21.970 | +1.8% | +| ref/novelty+trace | 22.088 | 22.700 | +2.8% | + +This is a measurable regression: nonempty single-observer batch medians increase +0.9–4.8%, and reference variants increase 1.4–2.8%. Empty-workload differences are +within 1.3% and do not establish an improvement. These results include validation +of the additional scalar and the extra trace field; no profiler-based attribution +of their individual costs was performed. + +For mixed/core at 500 candidates, the three process batch medians are +31,884.5 / 32,328.5 / 32,019.7 ns for v0.1.0 and +33,383.5 / 33,271.7 / 33,278.4 ns for the candidate. Per-call p99 ranges are +36.6–48.8 and 36.9–37.7 microseconds respectively. Mixed/novelty at 500 has p99 +ranges of 36.2–47.0 and 39.4–51.1 microseconds. Across all single-observer +configurations/processes the largest individual samples are 1.2103 ms (v0.1.0) +and 1.6740 ms (candidate). Tails vary with desktop scheduling; lower isolated +percentiles do not demonstrate a performance improvement or a latency guarantee. + +All six processes pass the harness's pairing, status, replay and trace-on/off +checks, with zero allocations in 3,604,480 measured single-observer calls and +200,000 reference calls per process. Nondefault directionality is separately +covered by the prepared allocation regression and correctness suites. An +independent 4,000-frame × 40-candidate Release replay against v0.1.0 also produces +identical binary serialization of every decision, observer-memory field and +pre-existing trace field at default directionality. From f50b092eca3ec73c24d1c9953b21f3347eaeb8ff Mon Sep 17 00:00:00 2001 From: Pullsar <64874805+PullsarDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:16:16 +0300 Subject: [PATCH 4/6] Reuse x64 trace padding for the novelty gate --- README.md | 9 +++++-- docs/benchmarks.md | 19 +++++++++----- include/interest/attention.hpp | 5 ++-- tests/smoke_test.cpp | 48 +++++++++++++++++++++++++++++++++- tests/step_c18_test.cpp | 2 +- 5 files changed, 71 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3c245fa..ddd029d 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,17 @@ 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 a trailing -`float CandidateTrace::noveltyGate`. These change public struct layouts; rebuild +`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. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 2908055..65532fa 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -130,12 +130,19 @@ Lower values reduce the novelty angle penalty while keeping distance and percept gating. The host still controls perception strength, event generation and candidate range. Capture and return remain conditional on effective scores and temporal policy. -Both this trailing snapshot field and the new trailing `CandidateTrace::noveltyGate` -change the public layout contract: rebuild consumers against matching headers and -library. On MSVC x64 the snapshot remains 104 bytes (the new float uses tail padding), -while each trace entry grows from 96 to 104 bytes. Capacity 500 therefore uses -52,000 bytes of optional trace storage, up from 48,000; scratch ID storage and -32-byte observer memory are unchanged. These are measured sizes, not ABI promises. +The trailing snapshot field and new `CandidateTrace::noveltyGate` change the public +layout contract: rebuild consumers against matching headers and library. The trace +field now sits immediately after `eventStrength`, before `eventNovelty`, reusing four +bytes of x64 alignment padding. The snapshot remains 104 bytes and the trace returns +to 96 bytes, with all v0.1.0 trace field offsets unchanged. Capacity 500 again uses +48,000 bytes of optional trace storage; scratch ID storage and 32-byte observer memory +are unchanged. The smoke test checks every legacy field offset on x64. These are +tested layouts, not ABI promises for other targets. Positional trace aggregate +initializers must account for the inserted field; named field access is unchanged. + +The following timing table records the initial implementation with a 104-byte trace. +It is retained as historical evidence; the focused layout comparison below measures +the subsequent 96-byte trace against the pre-cleanup PR head. The comparison on 2026-10-24 rebuilds tag `v0.1.0` (`f89159b`) and the event directionality implementation (`0f9d1b4`) with the same MSVC 19.38.33145 x64 Release settings, diff --git a/include/interest/attention.hpp b/include/interest/attention.hpp index 5ae7d6f..3dd6a3c 100644 --- a/include/interest/attention.hpp +++ b/include/interest/attention.hpp @@ -226,11 +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 - double eventNovelty{}; // strength * 2^(-age / halfLife), [0,1]; absent => 0 - double noveltyContribution{}; // beta * noveltyGate * eventNovelty, before saturation // 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 * noveltyGate * eventNovelty, before saturation }; // Optional caller-owned explanation of one successful Step; written only when Ok. 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_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; } From 7b35d483ffc4d28c1a3792815a0ab1c4c3282a00 Mon Sep 17 00:00:00 2001 From: Pullsar <64874805+PullsarDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:22:16 +0300 Subject: [PATCH 5/6] Record compact trace layout verification and Release measurements --- docs/benchmarks.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 65532fa..4b4e7f8 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -240,3 +240,80 @@ covered by the prepared allocation regression and correctness suites. An independent 4,000-frame × 40-candidate Release replay against v0.1.0 also produces identical binary serialization of every decision, observer-memory field and pre-existing trace field at default directionality. + +## Trace layout cleanup + +`noveltyGate` is placed at offset 76, between `eventStrength` and `eventNovelty`. +Local probes compiled the v0.1.0 header, pre-cleanup PR header (`c3ed2d7`) and compact +header: their trace sizes were 96 / 104 / 96 bytes, with all 17 legacy field offsets +identical. The regression now checks every legacy offset, the new offset, size and +alignment on x64. No packing directives, allocation changes or scoring changes are +involved. A trace buffer for 500 candidates returns from 52,000 to 48,000 bytes. + +Verified layouts (bytes; MSVC locally and in CI, GCC/Clang through the CI matrix): + +| Compiler / target | Size | Alignment | eventStrength | noveltyGate | eventNovelty | noveltyContribution | +|---|---:|---:|---:|---:|---:|---:| +| MSVC 19.38.33145 / Windows x64 | 96 | 8 | 72 | 76 | 80 | 88 | +| MSVC 19.51.36260 / Windows x64 (CI) | 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 | + +An independent 4,000-frame × 40-candidate replay matches every serialized decision, +observer-memory field and trace field, including `noveltyGate`, against the +pre-cleanup PR. It covers directionality 0, .25, .5, .75 and 1, invalid directionality, +beta zero and absent events. A separate default-directionality replay still matches +all pre-existing fields against v0.1.0 exactly. Padding bytes are excluded from +comparisons because they are not diagnostic values. + +Focused Release comparison on 2026-10-24: pre-cleanup PR `c3ed2d7` (104-byte trace) +versus layout cleanup `5eeb858` (96-byte trace), using MSVC 19.38.33145 x64, the same +Release flags, Windows desktop and High Performance power policy as above. Each +revision ran the unchanged 44-configuration harness and four reference variants +in three processes with `--runs 5 --cycles 64`. Process order was before/compact, +compact/before, before/compact. No compiler builds ran during timing. Default +inputs and scoring code were unchanged. The tables focus on mixed workloads and +reference variants. Single-observer values use the median of process batch medians; +reference values use the median of process aggregate times, as above. + +Batch nanoseconds per Step: + +| Configuration | Candidates | Before (104 bytes) | Compact (96 bytes) | Change | +|---|---:|---:|---:|---:| +| mixed/core | 100 | 6434.1 | 6517.7 | +1.3% | +| mixed/core | 500 | 33752.3 | 33807.4 | +0.2% | +| mixed/core+trace | 100 | 6789.2 | 6783.8 | -0.1% | +| mixed/core+trace | 500 | 36225.8 | 34875.5 | -3.7% | +| mixed/novelty | 100 | 6575.2 | 6448.0 | -1.9% | +| mixed/novelty | 500 | 35057.9 | 33783.9 | -3.6% | +| mixed/novelty+trace | 100 | 6876.9 | 6806.2 | -1.0% | +| mixed/novelty+trace | 500 | 35722.1 | 35414.8 | -0.9% | + +Reference kernel milliseconds per simulated second: + +| Configuration | Before (104 bytes) | Compact (96 bytes) | Change | +|---|---:|---:|---:| +| ref/core | 22.588 | 22.371 | -1.0% | +| ref/core,shared-order | 14.773 | 14.784 | +0.1% | +| ref/novelty | 22.299 | 23.295 | +4.5% | +| ref/novelty+trace | 23.139 | 23.423 | +1.2% | + +Across all nonempty single-observer configurations, measured batch changes range +from -3.7% to +1.6%; reference variants range from -1.0% to +4.5%. The observed ++4.5% reference-novelty regression is retained in the results. Run ranges overlap +and desktop scheduling noise is substantial, so these measurements do not establish +a general execution-speed improvement or isolate a layout-caused regression. +The confirmed benefit is the 4,000-byte reduction in 500-entry trace storage. + +For mixed/core+trace at 500 candidates, process batch medians are +36,225.8 / 35,355.4 / 39,251.5 ns before and +34,849.4 / 35,240.8 / 34,875.5 ns compact. Per-call p99 ranges are 47.6–59.4 and +48.4–53.8 microseconds respectively. Reference-novelty aggregate process values +span 22.183–24.335 ms before and 22.380–24.347 ms compact per simulated second. +The largest individual single-observer samples across all configurations/processes +are 2.2141 ms before and 3.8266 ms compact. These tails do not imply a latency bound. + +All six processes pass status, pairing, replay, trace-on/off and allocation checks, +with zero allocations in 1,802,240 measured single-observer calls and 200,000 +reference calls per process. The dedicated prepared allocation test also remains +at zero across 404 calls, including nondefault directionality and rejection paths. From 7910003f894a06dbfd39959ac8af9a96dea78d11 Mon Sep 17 00:00:00 2001 From: Pullsar <64874805+PullsarDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:45:48 +0300 Subject: [PATCH 6/6] Document final candidate benchmarks against v0.1.0 --- docs/benchmarks.md | 522 ++++++++++++++++++++------------------------- 1 file changed, 230 insertions(+), 292 deletions(-) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 4b4e7f8..825283c 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,319 +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 +``` + +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: + +```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() +``` -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. +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. -## Method and workloads +## 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. - -## Interpretation limits - -At default event directionality, 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. - -## Event directionality candidate (proposed v0.2.0) - -`CandidateSnapshot::eventDirectionality` changes novelty's angular gate, not raw -preference scoring. Its default of 1 reuses the legacy rounded `gate` exactly. -Lower values reduce the novelty angle penalty while keeping distance and perception -gating. The host still controls perception strength, event generation and candidate -range. Capture and return remain conditional on effective scores and temporal policy. - -The trailing snapshot field and new `CandidateTrace::noveltyGate` change the public -layout contract: rebuild consumers against matching headers and library. The trace -field now sits immediately after `eventStrength`, before `eventNovelty`, reusing four -bytes of x64 alignment padding. The snapshot remains 104 bytes and the trace returns -to 96 bytes, with all v0.1.0 trace field offsets unchanged. Capacity 500 again uses -48,000 bytes of optional trace storage; scratch ID storage and 32-byte observer memory -are unchanged. The smoke test checks every legacy field offset on x64. These are -tested layouts, not ABI promises for other targets. Positional trace aggregate -initializers must account for the inserted field; named field access is unchanged. - -The following timing table records the initial implementation with a 104-byte trace. -It is retained as historical evidence; the focused layout comparison below measures -the subsequent 96-byte trace against the pre-cleanup PR head. - -The comparison on 2026-10-24 rebuilds tag `v0.1.0` (`f89159b`) and the event -directionality implementation (`0f9d1b4`) with the same MSVC 19.38.33145 x64 Release settings, -Visual Studio generator, warnings as errors and no LTO/IPO. CPU, RAM, OS and power -policy match the recorded environment above; background desktop applications were -running, with no affinity, priority or frequency controls. No compiler builds ran -concurrently with the timed processes. - -Each revision runs the unchanged 44-configuration harness in three independent -processes, each with `--runs 5 --cycles 128`. Process order is baseline/candidate, -candidate/baseline, baseline/candidate to expose drift. All inputs retain default -directionality, so these measure compatibility overhead on existing workloads, -not the cost or behavior of nondefault directionality. Single-observer comparisons -use the median of three reported process batch medians; reference comparisons use -the median of the three aggregate kernel times per simulated second. Per-call tails -and all process results are inspected separately; timings are desktop observations. - -Measured batch nanoseconds per Step (default directionality throughout): - -| Configuration | Candidates | v0.1.0 | Candidate | Change | +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 | 7828.5 | 7970.6 | +1.8% | -| churn/core | 500 | 45798.2 | 46221.8 | +0.9% | -| churn/core+trace | 100 | 8063.0 | 8232.6 | +2.1% | -| churn/core+trace | 500 | 46743.8 | 47516.9 | +1.7% | -| far80/core | 100 | 4100.5 | 4255.1 | +3.8% | -| far80/core | 500 | 22575.2 | 23296.6 | +3.2% | -| far80/core+trace | 100 | 4617.0 | 4765.0 | +3.2% | -| far80/core+trace | 500 | 24943.8 | 25774.9 | +3.3% | -| front/core | 100 | 6948.0 | 7250.4 | +4.4% | -| front/core | 500 | 35678.9 | 37233.9 | +4.4% | -| front/core+trace | 100 | 7294.1 | 7587.7 | +4.0% | -| front/core+trace | 500 | 37266.3 | 38987.5 | +4.6% | -| mixed/core | 0 | 293.3 | 294.4 | +0.4% | -| mixed/core | 16 | 1180.8 | 1219.3 | +3.3% | -| mixed/core | 100 | 6150.1 | 6422.7 | +4.4% | -| mixed/core | 500 | 32019.7 | 33278.4 | +3.9% | -| mixed/core+trace | 0 | 291.7 | 291.5 | -0.1% | -| mixed/core+trace | 16 | 1231.7 | 1276.7 | +3.7% | -| mixed/core+trace | 100 | 6482.7 | 6773.7 | +4.5% | -| mixed/core+trace | 500 | 33533.5 | 35128.1 | +4.8% | -| mixed/events,beta0 | 0 | 291.8 | 291.3 | -0.2% | -| mixed/events,beta0 | 16 | 1188.4 | 1228.7 | +3.4% | -| mixed/events,beta0 | 100 | 6242.7 | 6489.5 | +4.0% | -| mixed/events,beta0 | 500 | 32287.8 | 33712.6 | +4.4% | -| mixed/novelty | 0 | 292.8 | 291.9 | -0.3% | -| mixed/novelty | 16 | 1197.3 | 1237.4 | +3.3% | -| mixed/novelty | 100 | 6260.2 | 6512.6 | +4.0% | -| mixed/novelty | 500 | 32361.4 | 33710.0 | +4.2% | -| mixed/novelty+trace | 0 | 295.6 | 291.9 | -1.3% | -| mixed/novelty+trace | 16 | 1253.3 | 1292.8 | +3.2% | -| mixed/novelty+trace | 100 | 6574.5 | 6858.9 | +4.3% | -| mixed/novelty+trace | 500 | 33989.5 | 35497.1 | +4.4% | -| reject50/core | 100 | 4711.8 | 4872.8 | +3.4% | -| reject50/core | 500 | 24198.0 | 25080.6 | +3.6% | -| reject50/core+trace | 100 | 5184.9 | 5358.5 | +3.3% | -| reject50/core+trace | 500 | 26547.4 | 27458.3 | +3.4% | -| shuffle/core | 100 | 7659.6 | 7791.6 | +1.7% | -| shuffle/core | 500 | 44783.3 | 45370.5 | +1.3% | -| shuffle/core+trace | 100 | 7884.1 | 8089.7 | +2.6% | -| shuffle/core+trace | 500 | 45897.8 | 46571.8 | +1.5% | -| surround/core | 100 | 6951.0 | 7250.4 | +4.3% | -| surround/core | 500 | 35741.6 | 37329.9 | +4.4% | -| surround/core+trace | 100 | 7275.9 | 7564.2 | +4.0% | -| surround/core+trace | 500 | 37471.1 | 39120.6 | +4.4% | - -Reference kernel milliseconds per simulated second (100 observers × 500 candidates × 5 Hz): - -| Configuration | v0.1.0 | 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.099 | 21.401 | +1.4% | -| ref/core,shared-order | 14.110 | 14.489 | +2.7% | -| ref/novelty | 21.572 | 21.970 | +1.8% | -| ref/novelty+trace | 22.088 | 22.700 | +2.8% | - -This is a measurable regression: nonempty single-observer batch medians increase -0.9–4.8%, and reference variants increase 1.4–2.8%. Empty-workload differences are -within 1.3% and do not establish an improvement. These results include validation -of the additional scalar and the extra trace field; no profiler-based attribution -of their individual costs was performed. - -For mixed/core at 500 candidates, the three process batch medians are -31,884.5 / 32,328.5 / 32,019.7 ns for v0.1.0 and -33,383.5 / 33,271.7 / 33,278.4 ns for the candidate. Per-call p99 ranges are -36.6–48.8 and 36.9–37.7 microseconds respectively. Mixed/novelty at 500 has p99 -ranges of 36.2–47.0 and 39.4–51.1 microseconds. Across all single-observer -configurations/processes the largest individual samples are 1.2103 ms (v0.1.0) -and 1.6740 ms (candidate). Tails vary with desktop scheduling; lower isolated -percentiles do not demonstrate a performance improvement or a latency guarantee. - -All six processes pass the harness's pairing, status, replay and trace-on/off -checks, with zero allocations in 3,604,480 measured single-observer calls and -200,000 reference calls per process. Nondefault directionality is separately -covered by the prepared allocation regression and correctness suites. An -independent 4,000-frame × 40-candidate Release replay against v0.1.0 also produces -identical binary serialization of every decision, observer-memory field and -pre-existing trace field at default directionality. +| 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% | -## Trace layout cleanup -`noveltyGate` is placed at offset 76, between `eventStrength` and `eventNovelty`. -Local probes compiled the v0.1.0 header, pre-cleanup PR header (`c3ed2d7`) and compact -header: their trace sizes were 96 / 104 / 96 bytes, with all 17 legacy field offsets -identical. The regression now checks every legacy offset, the new offset, size and -alignment on x64. No packing directives, allocation changes or scoring changes are -involved. A trace buffer for 500 candidates returns from 52,000 to 48,000 bytes. +## Latency and variability -Verified layouts (bytes; MSVC locally and in CI, GCC/Clang through the CI matrix): +Per-call p99 ranges across the three processes, microseconds, at 500 mixed candidates: -| Compiler / target | Size | Alignment | eventStrength | noveltyGate | eventNovelty | noveltyContribution | +| 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 (CI) | 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 | -An independent 4,000-frame × 40-candidate replay matches every serialized decision, -observer-memory field and trace field, including `noveltyGate`, against the -pre-cleanup PR. It covers directionality 0, .25, .5, .75 and 1, invalid directionality, -beta zero and absent events. A separate default-directionality replay still matches -all pre-existing fields against v0.1.0 exactly. Padding bytes are excluded from -comparisons because they are not diagnostic values. - -Focused Release comparison on 2026-10-24: pre-cleanup PR `c3ed2d7` (104-byte trace) -versus layout cleanup `5eeb858` (96-byte trace), using MSVC 19.38.33145 x64, the same -Release flags, Windows desktop and High Performance power policy as above. Each -revision ran the unchanged 44-configuration harness and four reference variants -in three processes with `--runs 5 --cycles 64`. Process order was before/compact, -compact/before, before/compact. No compiler builds ran during timing. Default -inputs and scoring code were unchanged. The tables focus on mixed workloads and -reference variants. Single-observer values use the median of process batch medians; -reference values use the median of process aggregate times, as above. - -Batch nanoseconds per Step: - -| Configuration | Candidates | Before (104 bytes) | Compact (96 bytes) | Change | -|---|---:|---:|---:|---:| -| mixed/core | 100 | 6434.1 | 6517.7 | +1.3% | -| mixed/core | 500 | 33752.3 | 33807.4 | +0.2% | -| mixed/core+trace | 100 | 6789.2 | 6783.8 | -0.1% | -| mixed/core+trace | 500 | 36225.8 | 34875.5 | -3.7% | -| mixed/novelty | 100 | 6575.2 | 6448.0 | -1.9% | -| mixed/novelty | 500 | 35057.9 | 33783.9 | -3.6% | -| mixed/novelty+trace | 100 | 6876.9 | 6806.2 | -1.0% | -| mixed/novelty+trace | 500 | 35722.1 | 35414.8 | -0.9% | - -Reference kernel milliseconds per simulated second: - -| Configuration | Before (104 bytes) | Compact (96 bytes) | Change | -|---|---:|---:|---:| -| ref/core | 22.588 | 22.371 | -1.0% | -| ref/core,shared-order | 14.773 | 14.784 | +0.1% | -| ref/novelty | 22.299 | 23.295 | +4.5% | -| ref/novelty+trace | 23.139 | 23.423 | +1.2% | - -Across all nonempty single-observer configurations, measured batch changes range -from -3.7% to +1.6%; reference variants range from -1.0% to +4.5%. The observed -+4.5% reference-novelty regression is retained in the results. Run ranges overlap -and desktop scheduling noise is substantial, so these measurements do not establish -a general execution-speed improvement or isolate a layout-caused regression. -The confirmed benefit is the 4,000-byte reduction in 500-entry trace storage. - -For mixed/core+trace at 500 candidates, process batch medians are -36,225.8 / 35,355.4 / 39,251.5 ns before and -34,849.4 / 35,240.8 / 34,875.5 ns compact. Per-call p99 ranges are 47.6–59.4 and -48.4–53.8 microseconds respectively. Reference-novelty aggregate process values -span 22.183–24.335 ms before and 22.380–24.347 ms compact per simulated second. -The largest individual single-observer samples across all configurations/processes -are 2.2141 ms before and 3.8266 ms compact. These tails do not imply a latency bound. - -All six processes pass status, pairing, replay, trace-on/off and allocation checks, -with zero allocations in 1,802,240 measured single-observer calls and 200,000 -reference calls per process. The dedicated prepared allocation test also remains -at zero across 404 calls, including nondefault directionality and rejection paths. +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 + +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.