Skip to content

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

present() -> Mapping[str, float]

The signals this source actually produced, in declaration order.

Source code in src/symfonic/capabilities/memory/relevance.py
def present(self) -> Mapping[str, float]:
    """The signals this source actually produced, in declaration order."""
    return {
        name: value
        for name in SIGNAL_NAMES
        if (value := getattr(self, name)) is not None
    }

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

compose(signals: RelevanceSignals) -> float

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
def compose(self, signals: RelevanceSignals) -> float:
    """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.
    """
    total = 0.0
    weighted = 0.0
    for name, value in signals.present().items():
        weight = getattr(self, name)
        total += weight
        weighted += weight * value
    return weighted / total if total > 0.0 else 0.0

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

gate(distance: int) -> float

The multiplier applied to a memory distance levels above the query.

Source code in src/symfonic/capabilities/memory/relevance.py
def gate(self, distance: int) -> float:
    """The multiplier applied to a memory ``distance`` levels above the query."""
    if distance < 0:
        raise MemoryContractError(
            f"scope blend was asked to gate distance {distance}; a negative distance is "
            "MemoryScope.distance's 'not visible from here', and scoring it would rank a "
            "memory the query cannot see."
        )
    if distance == 0 or self.mode is ScopeBlendMode.OFF:
        return 1.0
    decay = math.exp(-_LN2 / self.half_life_levels * distance)
    return self.floor + (1.0 - self.floor) * decay

rank_score

rank_score(score: float, distance: int) -> float

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
def rank_score(self, score: float, distance: int) -> float:
    """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.
    """
    gated = score * self.gate(distance)
    if self.mode is not ScopeBlendMode.BACKFILL:
        return gated
    if distance == 0:
        return _TIP_BAND + _TIP_BAND * gated
    return _TIP_BAND * gated

ScopeBlendMode

Bases: StrEnum

How an ancestor-scoped memory competes with a memory written here.

BACKFILL class-attribute instance-attribute

BACKFILL = 'backfill'

Ancestors rank strictly below every tip memory: they fill leftover slots.

BLEND class-attribute instance-attribute

BLEND = 'blend'

Distance attenuates the score. A strong ancestor can still beat a weak tip.

OFF class-attribute instance-attribute

OFF = 'off'

Distance does not affect the score. A tenant fact competes on merit.

cue_overlap

cue_overlap(cue: str, text: str) -> float

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.

Source code in src/symfonic/capabilities/memory/relevance.py
def cue_overlap(cue: str, text: str) -> float:
    """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.
    """
    wanted = _tokens(cue)
    if not wanted:
        return 1.0
    return len(wanted & _tokens(text)) / len(wanted)