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
25 changes: 25 additions & 0 deletions src/aelfrice/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@
BELIEF_CORRECTION: Final[str] = "correction"
BELIEF_PREFERENCE: Final[str] = "preference"
BELIEF_REQUIREMENT: Final[str] = "requirement"
# v2.1 #548 wonder lifecycle. Speculative beliefs are wonder-generated
# candidates pending promotion. Not user-facing until `aelf confirm`
# promotes them to a real type. C4 retags this → real type on threshold.
BELIEF_SPECULATIVE: Final[str] = "speculative"

BELIEF_TYPES: Final[frozenset[str]] = frozenset({
BELIEF_FACTUAL,
BELIEF_CORRECTION,
BELIEF_PREFERENCE,
BELIEF_REQUIREMENT,
BELIEF_SPECULATIVE,
})

# --- Edge types ---
Expand All @@ -32,6 +37,11 @@
EDGE_IMPLEMENTS: Final[str] = "IMPLEMENTS"
EDGE_TEMPORAL_NEXT: Final[str] = "TEMPORAL_NEXT"
EDGE_TESTS: Final[str] = "TESTS"
# v2.1 #548 wonder lifecycle. RESOLVES marks that a speculative phantom
# resolves (answers or supersedes) an existing belief. A phantom with any
# RESOLVES edge (incoming or outgoing) is excluded from GC — the edge
# signals human-observable intent that the phantom should persist.
EDGE_RESOLVES: Final[str] = "RESOLVES"

# Marker edge — semantically distinct from the relational edge types
# above. POTENTIALLY_STALE tags a target belief as suspected stale; it
Expand Down Expand Up @@ -61,6 +71,9 @@
# TESTS (0.55): evidential edge — source is a test belief, target is the
# spec/claim under test. Placed just below SUPPORTS (0.60) because a test
# asserts coverage of a claim rather than directly arguing for it.
# RESOLVES (0.0): wonder-lifecycle marker. No propagation valence because
# resolution intent (phantom answers an existing belief) doesn't carry
# evidential weight in the Bayesian update chain.
EDGE_VALENCE: Final[dict[str, float]] = {
EDGE_SUPPORTS: 1.0,
EDGE_CITES: 0.5,
Expand All @@ -71,6 +84,7 @@
EDGE_IMPLEMENTS: 0.65,
EDGE_TEMPORAL_NEXT: 0.2,
EDGE_TESTS: 0.55,
EDGE_RESOLVES: 0.0,
}

EDGE_TYPES: Final[frozenset[str]] = frozenset(EDGE_VALENCE.keys())
Expand Down Expand Up @@ -177,6 +191,11 @@ def retention_class_for_source(source_kind: str) -> str:
# downstream consumers know the row is migration-produced, not a live
# re-ingest.
CORROBORATION_SOURCE_CONSOLIDATION_MIGRATION: Final[str] = "consolidation_migration"
# v2.1 #548 wonder lifecycle. Records the `wonder_ingest` assertion that
# produced a speculative phantom. The `source_path_hash` field carries
# `"<generator>@<score:.4f>"` so the provenance is auditable without a
# dedicated audit_log table (Track A3 is deferred).
CORROBORATION_SOURCE_WONDER_INGEST: Final[str] = "wonder_ingest"

CORROBORATION_SOURCE_TYPES: Final[frozenset[str]] = frozenset({
CORROBORATION_SOURCE_COMMIT_INGEST,
Expand All @@ -185,6 +204,7 @@ def retention_class_for_source(source_kind: str) -> str:
CORROBORATION_SOURCE_FILESYSTEM_INGEST,
CORROBORATION_SOURCE_CLI_REMEMBER,
CORROBORATION_SOURCE_CONSOLIDATION_MIGRATION,
CORROBORATION_SOURCE_WONDER_INGEST,
})

# v2.0 #205 ingest_log source_kind enum. Wire-format strings; do not
Expand Down Expand Up @@ -250,6 +270,10 @@ class Belief:
is active. `activation_condition` is JSON-encoded TEXT when set.
Behavior (when to set, when to wake, predicate evaluator) is a
follow-up issue; this commit only locks in the round-trip shape.

`valid_to` (v2.1 #548) is the soft-delete timestamp for wonder GC.
NULL = active. Non-NULL = GC'd by `wonder_gc`; the belief is excluded
from retrieval once set. Only speculative phantoms are GC-eligible.
"""

id: str
Expand All @@ -269,6 +293,7 @@ class Belief:
hibernation_score: float | None = None
activation_condition: str | None = None
retention_class: str = RETENTION_UNKNOWN
valid_to: str | None = None


ANCHOR_TEXT_MAX_LEN: Final[int] = 1000
Expand Down
87 changes: 84 additions & 3 deletions src/aelfrice/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
INSERT_BELIEF_ALLOWLIST: Final[frozenset[str]] = frozenset({
"aelfrice.derivation_worker",
"aelfrice.wonder.simulator",
"aelfrice.wonder.lifecycle",
"aelfrice.benchmark",
"aelfrice.migrate",
})
Expand Down Expand Up @@ -449,13 +450,20 @@ def _check_insert_belief_authority() -> None:
# ADD COLUMN with a CHECK is brittle across SQLite versions.
# Python-side RETENTION_CLASSES validates inserts.
"ALTER TABLE beliefs ADD COLUMN retention_class TEXT NOT NULL DEFAULT 'unknown'",
# v2.1 #548 wonder lifecycle. Soft-delete timestamp: NULL = active,
# non-NULL = GC'd by `wonder_gc`. Existing rows default to NULL
# (active) which is correct — only speculative phantoms are GC'd.
"ALTER TABLE beliefs ADD COLUMN valid_to TEXT",
)

# Indexes that depend on migrated columns. Run after _MIGRATIONS so
# they see the post-ALTER schema.
_POST_MIGRATION_INDEXES: tuple[str, ...] = (
"CREATE INDEX IF NOT EXISTS idx_beliefs_session ON beliefs(session_id)",
"CREATE INDEX IF NOT EXISTS idx_beliefs_origin ON beliefs(origin)",
# v2.1 #548: partial index on active speculative beliefs for GC scans.
"CREATE INDEX IF NOT EXISTS idx_beliefs_speculative_gc "
"ON beliefs(origin, created_at) WHERE valid_to IS NULL",
)

# One-shot backfill for v1.0/v1.1 stores opening on v1.2+. Each row
Expand Down Expand Up @@ -507,6 +515,9 @@ def _row_to_belief(row: sqlite3.Row) -> Belief:
row["retention_class"] if "retention_class" in keys
else RETENTION_UNKNOWN
)
# valid_to column added in v2.1 (#548). Pre-migration rows default
# to None (active). Same fallback pattern as retention_class.
valid_to = row["valid_to"] if "valid_to" in keys else None
return Belief(
id=row["id"],
content=row["content"],
Expand All @@ -525,6 +536,7 @@ def _row_to_belief(row: sqlite3.Row) -> Belief:
hibernation_score=row["hibernation_score"],
activation_condition=row["activation_condition"],
retention_class=retention_class,
valid_to=valid_to,
)


Expand Down Expand Up @@ -1334,15 +1346,15 @@ def insert_belief(self, b: Belief) -> None:
lock_level, locked_at, demotion_pressure,
created_at, last_retrieved_at, session_id, origin,
hibernation_score, activation_condition,
retention_class
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
retention_class, valid_to
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
b.id, b.content, b.content_hash, b.alpha, b.beta, b.type,
b.lock_level, b.locked_at, b.demotion_pressure,
b.created_at, b.last_retrieved_at, b.session_id, b.origin,
b.hibernation_score, b.activation_condition,
b.retention_class,
b.retention_class, b.valid_to,
),
)
self._conn.execute(
Expand Down Expand Up @@ -1446,6 +1458,75 @@ def delete_belief(self, belief_id: str) -> None:
self._conn.commit()
self._fire_invalidation()

def soft_delete_belief(self, belief_id: str, ts: str | None = None) -> None:
"""Set `valid_to` on a belief to soft-delete it (v2.1 #548 wonder GC).

Only moves beliefs from active (valid_to IS NULL) to soft-deleted.
Calling this on an already-GC'd belief is a no-op (idempotent via
the WHERE clause). Does not remove edges or corroboration rows —
the phantom's evidence trail is preserved for audit.
"""
now = ts if ts is not None else datetime.now(timezone.utc).isoformat()
self._conn.execute(
"UPDATE beliefs SET valid_to = ? WHERE id = ? AND valid_to IS NULL",
(now, belief_id),
)
self._bump_belief_version(belief_id)
self._conn.commit()
self._fire_invalidation()

def query_wonder_gc_candidates(
self,
*,
cutoff_ts: str,
alpha_default: float = 0.3,
beta_default: float = 1.0,
alpha_epsilon: float = 1e-9,
beta_epsilon: float = 1e-9,
) -> list[str]:
"""Return belief IDs eligible for wonder GC (v2.1 #548).

Candidates satisfy ALL of:
- type = 'speculative'
- origin = ORIGIN_SPECULATIVE
- valid_to IS NULL (still active)
- created_at < cutoff_ts (older than ttl_days)
- alpha <= alpha_default + epsilon AND beta <= beta_default + epsilon
(priors unchanged from wonder_ingest defaults)
- no feedback_history rows (apply_feedback never called)
- no RESOLVES edges (incoming or outgoing)

The caller is responsible for computing `cutoff_ts` from `ttl_days`.
Returns a list of belief IDs; order is not guaranteed.
"""
cur = self._conn.execute(
"""
SELECT b.id
FROM beliefs b
WHERE b.type = 'speculative'
AND b.origin = 'speculative'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Use the ORIGIN_SPECULATIVE constant instead of hardcoding the origin string in the GC query.

Hardcoding b.origin = 'speculative' makes this query fragile if the origin wire value changes or differs from the literal. Using the existing constant (via interpolation or a parameter) keeps the query consistent with the rest of the codebase and prevents subtle divergence.

AND b.valid_to IS NULL
AND b.created_at < ?
AND b.alpha <= ?
AND b.beta <= ?
AND NOT EXISTS (
SELECT 1 FROM feedback_history fh
WHERE fh.belief_id = b.id
)
AND NOT EXISTS (
SELECT 1 FROM edges e
WHERE e.type = 'RESOLVES'
AND (e.src = b.id OR e.dst = b.id)
)
""",
(
cutoff_ts,
alpha_default + alpha_epsilon,
beta_default + beta_epsilon,
),
)
return [row["id"] for row in cur.fetchall()]

# --- Entity index (v1.3 L2.5 retrieval) ------------------------------

def _write_belief_entities(self, belief_id: str, content: str) -> None:
Expand Down
Loading
Loading