symfonic.core.learning.phases¶
phases ¶
Graph-based consolidation phase implementations.
Extracted from consolidation.py to keep each module under ~300 lines. Each function is a standalone async phase that operates on a GraphMemoryStore and returns a count of mutations performed.
Phases 1-5 live here (structural graph operations).
Phases 8-9 (maintenance) live in phases_maintenance.py.
Phase 10 (episodic summarization) lives in phases_episodic.py.
apply_soul_corrections ¶
Apply user corrections from recent nodes to the SOUL schema.
Source code in src/symfonic/core/learning/phases.py
consolidate_pending_edges
async
¶
consolidate_pending_edges(
graph: GraphMemoryStore,
scope: TenantScope,
all_nodes: list[MemoryNode],
pending_connections: list[dict[str, Any]],
) -> int
Write inferred pending_connections as real graph edges.
Source code in src/symfonic/core/learning/phases.py
create_cooccurrence_edges
async
¶
create_cooccurrence_edges(
graph: GraphMemoryStore,
scope: TenantScope,
recent_nodes: list[MemoryNode],
) -> int
Create CO_OCCURRED edges between nodes updated within 1 hour.
Weight reinforcement: repeated co-occurrence increments the edge weight rather than inserting a duplicate.
Degree cap: a node that already has >= MAX_CO_OCCUR_DEGREE CO_OCCURRED neighbours will not receive new CO_OCCURRED edges (existing edges still get reinforced).
Source code in src/symfonic/core/learning/phases.py
generate_meta_nodes
async
¶
generate_meta_nodes(
graph: GraphMemoryStore,
scope: TenantScope,
all_nodes: list[MemoryNode],
llm_summarise: Any | None = None,
) -> int
Create meta-nodes from clusters of related nodes.
Idempotence: nodes with the META: prefix are excluded from the
clustering input. Without this filter, a second consolidation run
groups every META:foo/META:bar node under the shared prefix
META and emits a META:META cascade node -- correct but
cosmetically noisy (first-run meta nodes trigger a second-run meta
of metas). Phase 4 is clustering RAW domain nodes; the existing
META: nodes are outputs of this phase, not inputs to it.
Source code in src/symfonic/core/learning/phases.py
prune_orphans
async
¶
Delete orphan nodes: few edges, stale, low confidence, or TTL-expired.
Source code in src/symfonic/core/learning/phases.py
strengthen
async
¶
strengthen(
graph: GraphMemoryStore,
scope: TenantScope,
recent_nodes: list[MemoryNode],
*,
spreading_weight: float = 0.5,
) -> int
Increment importance on recently accessed (recurring) nodes.
A node qualifies as "recurring" when EITHER:
-
It appears >= 2 times in
recent_nodes(duplicate-in-list signal). This covers access-log replay / neighbourhood traversal callers that intentionally feed per-hit records into the consolidator. -
Its combined recurrence score
access_count + spreading_weight * spreading_access_count >= 2. -
access_countis bumped byGraphMemoryStore.get_nodeon every direct fetch (one-shot reads). spreading_access_countis bumped byGraphMemoryStore.bump_spreadingon every BFS-induced visit (spreading activation throughGraphTraversal.bfs).
spreading_weight (default 0.5) comes from
FrameworkConfig.phase1_spreading_weight. At 0.5 the combined
score matches v6.1.x observable behaviour: two BFS visits count
like one direct fetch. Setting the weight to 0.0 yields the
direct-only semantic from the user task description.
Before the v6.0.x fix, Phase 1 silently never fired in production:
GraphMemoryStore.query_nodes returns each node exactly once, so
the duplicate-in-list count was always 1 and the < 2 guard always
skipped. Falling back to the per-node access_count surfaces the
access-recurrence signal already captured at retrieval time without
requiring a new DB schema or access log.
The count semantics of the list-duplicate path are preserved: a node that appears N>=2 times in the list still gets N successful update attempts (each subsequent read loads the freshly-boosted value).
ADR (v6.1 T02/T03, revised v6.2 T02): the canonical recurrence signal
is the combined access_count + weighted spreading_access_count.
v6.1 aligned spreading-activation reads with the single access_count
counter by routing GraphTraversal.bfs through store.get_node.
v6.2 decomposed the counter so callers can distinguish direct fetches
from one-shot spreading visits. BFS now bumps
spreading_access_count via GraphMemoryStore.bump_spreading.
Intentionally excluded from bumping: query_nodes (returns
candidate sets for scoring -- one query bumping N nodes would distort
the frequency signal that scoring.py also reads), and
shortest_path / graph_proximity (run inside the scoring
loop itself -- same contamination risk).
Source code in src/symfonic/core/learning/phases.py
tag_risk_nodes
async
¶
tag_risk_nodes(
graph: GraphMemoryStore,
scope: TenantScope,
recent_nodes: list[MemoryNode],
) -> int
Mark nodes with negative context as RISK_NODE.