Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
341 changes: 238 additions & 103 deletions docs/benchmarks.md

Large diffs are not rendered by default.

16 changes: 13 additions & 3 deletions include/interest/attention.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -116,7 +121,8 @@ struct AttentionProfile {
TimeMs minDwellMs{0};
// Caller-owned; must outlive the Step call. At least one entry.
std::span<const ContextPreference> 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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion src/step.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -222,6 +223,15 @@ void EvaluateCandidate(const CandidateSnapshot& candidate, bool duplicate,
out.distanceGate = static_cast<float>(distanceGate);
out.angleGate = static_cast<float>(angleGate);
out.gate = static_cast<float>(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<double>(candidate.eventDirectionality) * (1.0 - angleGate);
out.noveltyGate = static_cast<float>(
distanceGate * noveltyAngleGate * static_cast<double>(candidate.perceptionStrength));
}
out.affinity = affinity;
out.preference = static_cast<float>(preference);
// Selection compares this float, so the trace shows exactly the compared value.
Expand Down Expand Up @@ -447,7 +457,7 @@ StepStatus Step(const ObserverSnapshot& observer,
static_cast<double>(profile.noveltyHalfLifeMs));
}
evaluation.noveltyContribution = static_cast<double>(profile.noveltyBeta) *
static_cast<double>(evaluation.gate) * evaluation.eventNovelty;
static_cast<double>(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<float>((std::min)(
Expand Down
48 changes: 47 additions & 1 deletion tests/smoke_test.cpp
Original file line number Diff line number Diff line change
@@ -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 <cstddef>
#include <cstdio>
#include <cstring>
#include <type_traits>

namespace {

Expand All @@ -20,6 +23,48 @@ void Expect(bool condition, const char* description)
}
}

void CheckTraceLayout()
{
using interest::CandidateTrace;
static_assert(std::is_standard_layout_v<CandidateTrace>);
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()
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions tests/step_allocation_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ void PreparedStepsDoNotAllocate()
c.enabled = rng.Below(8) != 0;
c.eventTimeMs = -100;
c.eventStrength = 0.75f;
c.eventDirectionality = static_cast<float>(rng.Below(5)) / 4.f;
}
}
// Repeated population changes with eligible inputs and fresh events, independent
Expand All @@ -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<interest::TimeMs>((i + 1) * 100 - 50);
frames[i].back().eventStrength = 0.8f;
frames[i].back().eventDirectionality = static_cast<float>(j % 5) / 4.f;
}
}
const std::vector<CandidateSnapshot> oversized(kCapacity + 1, test::NonSpatial(1, 1.f, 1.f));
Expand Down
2 changes: 1 addition & 1 deletion tests/step_c18_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading