Skip to content

Commit ce8c1e4

Browse files
authored
Signal strongly negative scope scores (#76)
1 parent 79f24dd commit ce8c1e4

4 files changed

Lines changed: 114 additions & 3 deletions

File tree

docs/REPORT-memory-operating-layer-eval.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,31 @@ uv run --extra sqlite python eval/scripts/memory_operating_poc.py \
383383
- scope score가 음수로 누적된 node/edge를 `memory_health()`에서 직접 확인할 수 있다.
384384
- reinforced summary는 양수 score만, demoted summary는 음수 score만 분리해서 보여준다.
385385

386+
후속 strong negative scope score signal guard 검증:
387+
388+
```bash
389+
uv run --extra sqlite python eval/scripts/memory_operating_poc.py \
390+
--results ~/synaptic-eval/memory_operating_strong_negative_signal_results.json
391+
```
392+
393+
결과:
394+
395+
| 항목 ||
396+
|---|---:|
397+
| result | PASS |
398+
| gates | `33/33` |
399+
| strong_negative_scope_score_signal_created | `true` |
400+
| signal_kind | `repeated_failure` |
401+
| score_signal_type | `strong_negative_scope_score` |
402+
| signal tags | `_memory_signal`, `_memory_suspect` |
403+
| score | `-1.000000` |
404+
405+
추가로 검증된 동작:
406+
407+
- `failure_count >= 3`이 아니어도 scope score가 강하게 음수이면 suspect signal 후보가 된다.
408+
- top demoted memory가 health summary에만 머물지 않고 pollution monitor signal로 연결된다.
409+
- signal provenance에는 `score_scope_key`, feedback count, score가 metadata로 남는다.
410+
386411
### 4. OpenIE off baseline smoke
387412

388413
```bash

eval/scripts/memory_operating_poc.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,16 @@ async def run_memory_operating_poc(args: argparse.Namespace) -> dict[str, Any]:
537537
)
538538
)
539539
ranking_health = await graph.memory_health(scope=scope)
540+
ranking_signal_nodes = await backend.list_nodes(kind=NodeKind.OBSERVATION, limit=1000)
541+
strong_negative_signal = next(
542+
(
543+
node
544+
for node in ranking_signal_nodes
545+
if node.properties.get("score_signal_type") == "strong_negative_scope_score"
546+
and scope_demoted.id in node.properties.get("node_ids", "")
547+
),
548+
None,
549+
)
540550

541551
selected_before_success = (
542552
selected_before_feedback.success_count if selected_before_feedback else -1
@@ -611,6 +621,10 @@ async def run_memory_operating_poc(args: argparse.Namespace) -> dict[str, Any]:
611621
and consolidation_candidate.id in consolidation_result.nodes_updated
612622
),
613623
"scope_score_repeated_failure_signal_created": (scope_score_failure_signal is not None),
624+
"strong_negative_scope_score_signal_created": (
625+
strong_negative_signal is not None
626+
and "_memory_suspect" in (strong_negative_signal.tags or [])
627+
),
614628
"entity_property_conflict_signal_created": (property_conflict_signal is not None),
615629
"superseded_target_stale_signal_created": (superseded_stale_signal is not None),
616630
"signal_events_recorded_idempotently": (
@@ -749,6 +763,15 @@ async def run_memory_operating_poc(args: argparse.Namespace) -> dict[str, Any]:
749763
"scope_score_failure_signal": (
750764
asdict(scope_score_failure_signal) if scope_score_failure_signal else {}
751765
),
766+
"strong_negative_scope_score_signal": (
767+
{
768+
"id": strong_negative_signal.id,
769+
"tags": list(strong_negative_signal.tags or []),
770+
"properties": dict(strong_negative_signal.properties or {}),
771+
}
772+
if strong_negative_signal
773+
else {}
774+
),
752775
"property_conflict_signal": (
753776
asdict(property_conflict_signal) if property_conflict_signal else {}
754777
),

src/synaptic/graph.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ def _infer_openie_model_profile(model: str) -> str:
239239

240240
_MEMORY_SIGNAL_MIN_PENALTY_CONFIDENCE = 0.7
241241
_MEMORY_SIGNAL_MAX_RANKING_PENALTY = 0.05
242+
_MEMORY_STRONG_NEGATIVE_SCORE_SIGNAL_THRESHOLD = -0.5
242243
_MEMORY_DRIFT_MIN_FAILURES = 3
243244
_MEMORY_DRIFT_MIN_FAILURE_RATE = 0.25
244245
_MEMORY_PROPERTY_CONFLICT_IGNORED_KEYS = frozenset(
@@ -3194,22 +3195,41 @@ async def scan_memory_signals(
31943195
target = score.node_id or score.edge_id
31953196
if not target or target in repeated_failure_targets:
31963197
continue
3197-
if score.failure_count < 3 or score.failure_count <= score.success_count:
3198+
repeated_failure = (
3199+
score.failure_count >= 3 and score.failure_count > score.success_count
3200+
)
3201+
strong_negative = score.score <= _MEMORY_STRONG_NEGATIVE_SCORE_SIGNAL_THRESHOLD
3202+
if not repeated_failure and not strong_negative:
31983203
continue
31993204
node_ids = [score.node_id] if score.node_id else []
32003205
edge_ids = [score.edge_id] if score.edge_id else []
3206+
signal_type = (
3207+
"repeated_negative_feedback"
3208+
if repeated_failure
3209+
else "strong_negative_scope_score"
3210+
)
3211+
confidence = (
3212+
0.75 if repeated_failure else min(0.9, max(0.7, 0.55 + abs(score.score) * 0.35))
3213+
)
3214+
reason = (
3215+
"scope score has repeated negative retrieval feedback"
3216+
if repeated_failure
3217+
else "scope score is strongly negative from retrieval feedback"
3218+
)
32013219
signals.append(
32023220
self._make_signal(
32033221
MemorySignalKind.REPEATED_FAILURE,
32043222
signal_scope,
32053223
node_ids=node_ids,
32063224
edge_ids=edge_ids,
3207-
confidence=0.75,
3208-
reason="scope score has repeated negative retrieval feedback",
3225+
confidence=confidence,
3226+
reason=reason,
32093227
properties={
3228+
"score_signal_type": signal_type,
32103229
"score_scope_key": score.scope_key,
32113230
"score_failure_count": str(score.failure_count),
32123231
"score_success_count": str(score.success_count),
3232+
"score_access_count": str(score.access_count),
32133233
"score": f"{score.score:.6f}",
32143234
},
32153235
)

tests/test_memory_operating_layer.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,6 +1507,49 @@ async def test_memory_monitor_flags_scope_score_repeated_failures_without_node_p
15071507
assert health.repeated_failure_count >= 1
15081508

15091509

1510+
@pytest.mark.asyncio
1511+
async def test_memory_monitor_flags_strong_negative_scope_score_as_suspect():
1512+
backend = MemoryBackend()
1513+
graph = SynapticGraph(backend)
1514+
scope = MemoryScope(user_id="u1")
1515+
node = Node(id="strong_negative", title="Strong negative memory")
1516+
await backend.save_node(node)
1517+
await backend.save_memory_score(
1518+
MemoryScore(
1519+
scope_key=scope.key,
1520+
node_id=node.id,
1521+
access_count=1,
1522+
success_count=0,
1523+
failure_count=0,
1524+
score=-0.75,
1525+
)
1526+
)
1527+
1528+
signals = await graph.scan_memory_signals(scope=scope)
1529+
repeated = [
1530+
signal
1531+
for signal in signals
1532+
if MemorySignalKind(str(signal.kind)) == MemorySignalKind.REPEATED_FAILURE
1533+
and node.id in signal.node_ids
1534+
]
1535+
1536+
assert repeated
1537+
assert repeated[0].properties["score_signal_type"] == "strong_negative_scope_score"
1538+
assert repeated[0].properties["score_scope_key"] == scope.key
1539+
assert repeated[0].properties["score"] == "-0.750000"
1540+
signal_nodes = await backend.list_nodes(kind=NodeKind.OBSERVATION, limit=100)
1541+
signal_node = next(
1542+
signal_node
1543+
for signal_node in signal_nodes
1544+
if signal_node.properties.get("score_signal_type") == "strong_negative_scope_score"
1545+
)
1546+
assert "_memory_suspect" in signal_node.tags
1547+
1548+
health = await graph.memory_health(scope=scope)
1549+
assert node.id in health.top_demoted_node_ids
1550+
assert health.repeated_failure_count >= 1
1551+
1552+
15101553
@pytest.mark.asyncio
15111554
async def test_memory_monitor_flags_entity_property_conflicts_by_source():
15121555
backend = MemoryBackend()

0 commit comments

Comments
 (0)