Skip to content

symfonic.core.learning.phases_entity

phases_entity

Phase 12.5 -- EntityLinker consolidation phase (Item 12 / 7.2).

Scans recent episodics, extracts entity mentions with the configured extractor, resolves them to canonical semantic nodes (creating new nodes when needed), and emits MENTIONS edges (provenance='entity_linker') between co-mentioned entity nodes with the originating episodic ids stored on MemoryEdge.source_episodic_ids.

Idempotent across re-runs when the configured extractor is deterministic (regex / spaCy). See design §9. Default-off behind FrameworkConfig.enable_entity_linker.

Roadmap reference: .claude/docs/2026-05-16-7.1.x-7.2-roadmap.md §11. Design note: .claude/docs/2026-05-23-entity-linker-design.md.

RegexHeuristicExtractor

Decision 4.5 default strategy.

Returns kind='other' and confidence=0.4 for all capitalised tokens (single or whitespace-joined) that pass a small stoplist. The default confidence_threshold=0.5 therefore filters every candidate out by design (§16.0 Q2). Users who want regex extraction must either lower the threshold to 0.4 or switch to the spaCy / LLM extractor.

build_entity_links(
    graph: Any,
    episodic_layer: Any,
    scope: TenantScope,
    *,
    extractor: EntityExtractor,
    min_mention_count: int = DEFAULT_MIN_MENTION_COUNT,
    max_episodics_per_run: int = DEFAULT_MAX_EPISODICS_PER_RUN,
    confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD,
) -> tuple[int, int]

Phase 12.5 implementation.

Returns (entity_nodes_created, mentions_edges_created).

See design §10 for acceptance contract and §9 for idempotency. Per §16.0 Q6, emitted edges are entity-to-entity MENTIONS with co-mention episodic ids on MemoryEdge.source_episodic_ids.

Source code in src/symfonic/core/learning/phases_entity.py
async def build_entity_links(
    graph: Any,
    episodic_layer: Any,
    scope: TenantScope,
    *,
    extractor: EntityExtractor,
    min_mention_count: int = DEFAULT_MIN_MENTION_COUNT,
    max_episodics_per_run: int = DEFAULT_MAX_EPISODICS_PER_RUN,
    confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD,
) -> tuple[int, int]:
    """Phase 12.5 implementation.

    Returns ``(entity_nodes_created, mentions_edges_created)``.

    See design §10 for acceptance contract and §9 for idempotency.
    Per §16.0 Q6, emitted edges are entity-to-entity ``MENTIONS`` with
    co-mention episodic ids on ``MemoryEdge.source_episodic_ids``.
    """
    # Step 1: scan episodics and collect canonical -> episodic-id list.
    mentions = await scan_episodics(
        episodic_layer, scope, extractor,
        confidence_threshold=confidence_threshold,
        max_episodics=max_episodics_per_run,
    )
    if not mentions:
        return (0, 0)

    # Step 2: apply mention-count gate (deduped episodic-id sets).
    qualified = {
        key: episodic_ids
        for key, episodic_ids in mentions.items()
        if len(set(episodic_ids)) >= min_mention_count
    }
    if not qualified:
        return (0, 0)

    # Step 3: load existing entity nodes and resolve / create.
    existing_entities = await load_existing_entity_nodes(graph, scope)
    extractor_name = type(extractor).__name__
    canonical_to_node: dict[tuple[str, str], MemoryNode] = {}
    nodes_created = 0
    for (canonical, kind), episodic_ids in qualified.items():
        max_conf = max(
            confidence_log_get((canonical, kind)),
            default=0.0,
        )
        node, created = await resolve_or_create_entity_node(
            graph, scope, canonical, kind,  # type: ignore[arg-type]
            episodic_ids=list(set(episodic_ids)),
            extractor_name=extractor_name,
            max_confidence=max_conf,
            existing_entity_nodes=existing_entities,
        )
        if node is None:
            continue
        canonical_to_node[(canonical, kind)] = node
        if created:
            nodes_created += 1
            existing_entities[(canonical, kind)] = node

    # Step 4: emit entity-to-entity MENTIONS edges for co-mentions.
    edges_created = await _emit_mentions_edges(
        graph, scope, qualified, canonical_to_node,
    )
    logger.info(
        "entity_links: tenant=%s nodes_created=%d edges_created=%d "
        "qualified_entities=%d",
        scope.tenant_id, nodes_created, edges_created, len(qualified),
    )
    return (nodes_created, edges_created)