NegativeReinforcementHandler -- logarithmic decay on user correction.
Default CallbackHandler helper that walks the turn's inference_paths
and applies a logarithmic negative decay to each traversed edge weight.
Stateless except for the injected graph store. Safe to reuse across turns.
NegativeReinforcementHandler
NegativeReinforcementHandler(graph_store: Any)
Walks traversed edges from the corrected turn and decays their weights.
Uses a logarithmic decay formula
w_new = w_old * (1 - 1/log(uses + e))
with a floor of 0.01 to prevent total zeroing.
Source code in src/symfonic/core/callbacks/correction.py
| def __init__(self, graph_store: Any) -> None:
self._graph = graph_store
|
decayed_weight
staticmethod
decayed_weight(old_weight: float, uses: int) -> float
Pure function -- exposed for unit testing.
Formula: w_new = w_old * (1 - 1/log(uses + e))
where e = math.e so uses=0 gives factor (1 - 1/1.0) = 0.0 (hard reset).
Floor at 0.01 to prevent complete zeroing / division issues.
Source code in src/symfonic/core/callbacks/correction.py
| @staticmethod
def decayed_weight(old_weight: float, uses: int) -> float:
"""Pure function -- exposed for unit testing.
Formula: w_new = w_old * (1 - 1/log(uses + e))
where e = math.e so uses=0 gives factor (1 - 1/1.0) = 0.0 (hard reset).
Floor at 0.01 to prevent complete zeroing / division issues.
"""
factor = 1.0 - (1.0 / math.log(uses + math.e))
new = old_weight * factor
return max(new, 0.01)
|
on_user_correction
async
on_user_correction(event: UserCorrectionEvent) -> None
Public entrypoint. Iterates every edge in activation_log.traversed_edges
(falls back to inference_paths label-pair lookup when absent) and
calls _decay_edge() once per edge.
Idempotence: per-turn uses snapshot per edge_id. Repeated application
of the same correction with the same snapshot yields the same result.
Source code in src/symfonic/core/callbacks/correction.py
| async def on_user_correction(self, event: UserCorrectionEvent) -> None:
"""Public entrypoint. Iterates every edge in activation_log.traversed_edges
(falls back to inference_paths label-pair lookup when absent) and
calls _decay_edge() once per edge.
Idempotence: per-turn uses snapshot per edge_id. Repeated application
of the same correction with the same snapshot yields the same result.
"""
from symfonic.memory.types import TenantScope
# T-7.21.8 (Slice B site #7): prefer event.scope when set so
# sub_tenant_id + namespace survive the correction-decay path.
# Pre-7.21 the bare reconstruction from event.tenant_id silently
# dropped both -- a multi-brand tenant correction would decay
# edges in the tenant-root partition instead of the brand
# partition where the original turn was scoped.
scope = (
event.scope
if event.scope is not None
else TenantScope(tenant_id=event.tenant_id)
)
log = event.activation_log or {}
# Prefer explicit traversed_edges when present
edges_raw = log.get("traversed_edges") or []
if not edges_raw:
# Fallback: walk inference_paths as label pairs
paths = log.get("inference_paths") or []
edges_raw = [
{
"source_label": p[0],
"target_label": p[-1],
"relationship": "ASSOCIATED",
}
for p in paths
if len(p) >= 2
]
if not edges_raw:
return
# Snapshot uses for idempotence within this turn
snapshot: dict[str, int] = {}
for e_info in edges_raw:
edge = await self._resolve_edge(scope, e_info)
if edge is None:
continue
owner_path = getattr(edge, "_scope_path", None)
if not isinstance(owner_path, str):
owner_path = scope.ancestor_prefix_paths()[0]
if owner_path != scope.scope_path:
continue
key = str(edge.id)
if key not in snapshot:
snapshot[key] = edge.uses
new_weight = self.decayed_weight(edge.weight, snapshot[key])
try:
props = {"weight": new_weight, "provenance": "correction"}
if callable(getattr(type(self._graph), "_upsert_resolved_edge_props", None)):
await self._graph._upsert_resolved_edge_props(scope, edge, props)
else:
await self._graph.upsert_edge_props(scope, edge.id, props)
except Exception:
logger.debug("Correction decay failed for edge %s", edge.id,
exc_info=True)
|