symfonic.capabilities.memory.relevance¶
relevance ¶
How much a memory is worth surfacing, and how far it may have travelled.
Three pieces, deliberately separated because they answer different questions and are configured by different people:
- :class:
RelevanceSignals— what a backend observed about one memory. A vector store contributes a cosine; a graph store contributes proximity; a keyword layer contributes neither. - :class:
RelevanceWeights— what a deployment believes those signals are worth. Owned by the operator, never by a backend. - :class:
ScopeBlend— how a memory written further up the hierarchy competes with one written right here. Owned by the product: it is the difference between "a tenant fact is as good as a session fact" and "a tenant fact only fills a gap".
Absence is not zero. Every signal is float | None, and the composite is
the weighted mean over the signals that are present. This is not a nicety: a
keyword-only layer has no similarity score, and folding that in as "similarity
0" drags every one of its memories below any relevance floor — which deletes the
layer while looking like tuning. The facade had the same rule buried in a
comment ("entries without a score are left untouched"); here it is the type.
The composite is a mean, so it stays in [0, 1] under any weighting. An
operator who multiplies every weight by seven changes nothing, and a floor like
min_relevance=0.4 means the same thing in every deployment. A raw weighted
sum would have made both of those false.
RelevanceSignals
dataclass
¶
RelevanceSignals(cue: float | None = None, salience: float | None = None, recency: float | None = None, frequency: float | None = None, proximity: float | None = None)
What a backend observed about one memory. None means "no such signal".
The distinction between 0.0 and None is the whole point: 0.0 is
an observation ("this memory does not match the cue"), None is the
absence of one ("this layer cannot answer that question").
present ¶
The signals this source actually produced, in declaration order.
RelevanceWeights
dataclass
¶
RelevanceWeights(cue: float = 1.0, salience: float = 0.5, recency: float = 0.3, frequency: float = 0.2, proximity: float = 0.2)
What a deployment believes each signal is worth.
Defaults lead with the cue, because the question a user just asked is the strongest evidence about what they want remembered, and keep salience second so a cold-start turn with no cue still surfaces a profile rather than noise.
compose ¶
The weighted mean of the signals that are present, in [0, 1].
Signals the source did not produce are absent from both the numerator and the denominator, so a layer that can only report salience is scored on salience rather than punished for its silence.
Source code in src/symfonic/capabilities/memory/relevance.py
ScopeBlend
dataclass
¶
ScopeBlend(mode: ScopeBlendMode = ScopeBlendMode.OFF, half_life_levels: float = 1.0, floor: float = 0.0)
How hierarchy distance is folded into the rank score.
gate ¶
The multiplier applied to a memory distance levels above the query.
Source code in src/symfonic/capabilities/memory/relevance.py
rank_score ¶
Fold distance into score, staying inside [0, 1].
Under :attr:ScopeBlendMode.BACKFILL the two bands are disjoint — tips
land in [0.5, 1] and ancestors in [0, 0.5) — so "every tip
first" is a property of the number rather than of a second sort pass
that some caller might skip. Ordering within each band is preserved,
and the gate still runs inside the ancestor band so a parent outranks a
grandparent.
Source code in src/symfonic/capabilities/memory/relevance.py
ScopeBlendMode ¶
Bases: StrEnum
How an ancestor-scoped memory competes with a memory written here.
BACKFILL
class-attribute
instance-attribute
¶
Ancestors rank strictly below every tip memory: they fill leftover slots.
BLEND
class-attribute
instance-attribute
¶
Distance attenuates the score. A strong ancestor can still beat a weak tip.
OFF
class-attribute
instance-attribute
¶
Distance does not affect the score. A tenant fact competes on merit.
cue_overlap ¶
The fraction of the cue's vocabulary that text covers, in [0, 1].
Normalised by the cue, never by the memory: dividing by the memory's own length would let a long memory win by containing more words than the question asked about, and dividing by the union would make every long memory lose for the same reason.
An empty cue scores 1.0. That is the cold-start turn — a first message
with no retrievable topic still wants the user's profile — and it hands
ranking entirely to the remaining signals rather than flattening them all to
zero.