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
14 changes: 13 additions & 1 deletion src/aelfrice/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -4230,10 +4230,21 @@ def find_promotable_snapshots(
Per docs/design/historical/belief_retention_class.md §4 the promotion rule is:

retention_class = 'snapshot'
AND origin != 'speculative'
AND COUNT(corroborations) >= min_corroborations
AND COUNT(DISTINCT corroborations.session_id) >= min_sessions
AND no inbound CONTRADICTS edge targets the belief

``origin != 'speculative'`` keeps phantoms out of the count-driven
retention promotion (#1132). Phantoms ingest with
``retention_class='snapshot'`` (``wonder_ingest``), so without this
guard a corroborated phantom would be flipped to ``retention_class
='fact'`` on a pure corroboration count — the exact trigger shape the
ratified #229 rule rejects on the origin axis. Phantom durability, like
phantom trust promotion, routes only through explicit acknowledgment
(``aelf validate`` / lock-match, #550). Retention and origin stay
orthogonal, but neither advances a speculative belief on a count alone.

``session_id`` may be NULL on legacy corroboration rows (#192 T3
backfill not yet landed). NULLs are excluded from the distinct
count rather than treated as a single anonymous session — that
Expand All @@ -4255,6 +4266,7 @@ def find_promotable_snapshots(
GROUP BY belief_id
) bc ON bc.belief_id = b.id
WHERE b.retention_class = 'snapshot'
AND b.origin != ?
AND bc.n_corr >= ?
AND bc.n_sess >= ?
AND NOT EXISTS (
Expand All @@ -4264,7 +4276,7 @@ def find_promotable_snapshots(
ORDER BY b.created_at ASC
{limit_clause}
""",
(int(min_corroborations), int(min_sessions)),
(ORIGIN_SPECULATIVE, int(min_corroborations), int(min_sessions)),
)
return [_row_to_belief(r) for r in cur.fetchall()]

Expand Down
63 changes: 62 additions & 1 deletion tests/test_doctor_promote_retention.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@
)
from aelfrice.models import (
BELIEF_FACTUAL,
BELIEF_SPECULATIVE,
EDGE_CONTRADICTS,
LOCK_NONE,
ORIGIN_SPECULATIVE,
ORIGIN_UNKNOWN,
RETENTION_FACT,
RETENTION_SNAPSHOT,
Belief,
Expand All @@ -46,14 +49,17 @@ def _mk(
bid: str,
*,
retention_class: str = RETENTION_SNAPSHOT,
origin: str = ORIGIN_UNKNOWN,
belief_type: str = BELIEF_FACTUAL,
) -> Belief:
b = Belief(
id=bid,
content=f"content for {bid}",
content_hash=f"h_{bid}",
alpha=1.0,
beta=1.0,
type=BELIEF_FACTUAL,
type=belief_type,
origin=origin,
lock_level=LOCK_NONE,
locked_at=None,
created_at="2026-04-26T00:00:00Z",
Expand Down Expand Up @@ -155,6 +161,61 @@ def test_promotable_returns_qualifying_belief(tmp_path: Path) -> None:
assert [b.id for b in promotable] == ["b1"]


def test_promotable_excludes_speculative_origin_phantoms(
tmp_path: Path,
) -> None:
"""#1132: a phantom (origin='speculative') ingests with
retention_class='snapshot', so without the origin guard a corroborated
phantom would be flipped to retention_class='fact' on a pure count
trigger — the trigger shape #229 rejects on the origin axis. Phantom
durability must route only through explicit acknowledgment, so the
corroboration-driven retention promotion excludes speculative origin.

Contrast with test_promotable_returns_qualifying_belief: identical
corroboration profile (N=3 across 3 sessions), differing only in origin,
yet the phantom is excluded and the ordinary snapshot is promotable.
"""
store = MemoryStore(str(tmp_path / "m.db"))
_mk(
store,
"phantom",
origin=ORIGIN_SPECULATIVE,
belief_type=BELIEF_SPECULATIVE,
)
_corr(store, "phantom", session="s1")
_corr(store, "phantom", session="s2")
_corr(store, "phantom", session="s3")
# Meets N>=3 / M>=2 / no-CONTRADICTS, but is speculative-origin.
assert store.find_promotable_snapshots() == []


def test_promote_retention_leaves_phantom_untouched(tmp_path: Path) -> None:
"""#1132 end-to-end: promote_retention must not flip a corroborated
phantom's retention_class, nor write a retention_promotion feedback row."""
store = MemoryStore(str(tmp_path / "m.db"))
_mk(
store,
"phantom",
origin=ORIGIN_SPECULATIVE,
belief_type=BELIEF_SPECULATIVE,
)
_corr(store, "phantom", session="s1")
_corr(store, "phantom", session="s2")
_corr(store, "phantom", session="s3")

report = promote_retention(store, dry_run=False)
assert report.candidates_found == 0
assert report.promoted == 0

refreshed = store.get_belief("phantom")
assert refreshed is not None
assert refreshed.retention_class == RETENTION_SNAPSHOT
events = store.list_feedback_events(belief_id="phantom")
assert all(
e.source != FEEDBACK_SOURCE_RETENTION_PROMOTION for e in events
)


# --- set_retention_class --------------------------------------------------


Expand Down
Loading