orchestrata_ultimate.knowledge_graph.ontology_mapping

Reference › orchestrata_ultimate › knowledge_graph › ontology_mapping

A pure mapping from the labels found in a live graph to the canonical ontology defined in canonical_schema (Spec 106, REQ-106-001 to -003). It performs no I/O; an importer applies it node by node to the operational graph (orchkg on orchgraph). Duplicate label trees converge: for example CodeClass maps to Class, CodeFunction and Method to Function, Specification to Spec, Agent to Volk, Run to TestRun and WorkItem to Task. Operational events and messages (CXPPacket to Message, GatewayAuditEvent to GatewayEvent, governance and lifecycle events) are imported at full fidelity as Tier-H records rather than discarded.

The data is in SOURCE_LABEL_MAP, REL_MAP (containment synonyms fold into DEFINES), KEY_DERIVATION (per-label functions such as _function_class_key, _session_key, _memory_key and _event_key that derive an identity when a node has no canonical key; code entities use file_path::qualname) and the label sets ADDED_ONLY_LABELS and FOLDED_CANONICAL_LABELS. resolve_canonical_label picks one canonical label from a node's label set, map_node returns a frozen Mapped (label, key name, key value) or a Skipped with a machine-readable reason, map_relationship renames an edge type, and run_id_for_thread gives the pipeline-run id for a conversation thread. A node that cannot produce a key is reported, never fabricated. Only tests import the module.

At a glance

Source src/orchestrata_ultimate/knowledge_graph/ontology_mapping.py
Lines of code 456
Classes 2
Functions 12
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.knowledge_graph, orchestrata_ultimate.knowledge_graph.canonical_schema
Imported by none
Tests that import it tests/knowledge_graph/test_ontology_mapping.py

Class diagram

Class diagram orchestrata_ultimate.knowledge_graph.ontology_mapping

Dependency diagram

Dependency diagram orchestrata_ultimate.knowledge_graph.ontology_mapping

API

Constants

Name Type Value
SOURCE_LABEL_MAP dict[str, str | None] {'Module': cs.LABEL_MODULE, 'Package': cs.LABEL_PACKAGE, 'File': cs.LABEL_FILE,…
ADDED_ONLY_LABELS frozenset[str] frozenset({cs.LABEL_TOOL, cs.LABEL_SEMANTIC_CHUNK, cs.LABEL_CHANGE, cs.LABEL_RE…
FOLDED_CANONICAL_LABELS frozenset[str] frozenset((source for source, target in SOURCE_LABEL_MAP.items() if source in c…
KEY_DERIVATION dict[str, Callable[[dict[str, Any]], str | None]] {'Function': _function_class_key, 'Class': _function_class_key, 'CodeFunction':…
REL_MAP dict[str, str] {'HAS_CLASS': cs.REL_DEFINES, 'HAS_FUNCTION': cs.REL_DEFINES, 'HAS_METHOD': cs.…
THREAD_PROVENANCE_LABELS tuple[str, ...] ('CXPPacket', 'Artifact')

Classes

Mapped (dataclass)

Decorators: traces(spec='106', reqs=['REQ-106-001', 'REQ-106-002', 'REQ-106-003']), dataclass(frozen=True) · Line 191

A source node resolved to its canonical shape, ready to MERGE.

Attribute Type Default
label str
key str
key_value str
properties dict[str, Any] field(default_factory=dict)

Skipped (dataclass)

Decorators: traces(spec='106', reqs=['REQ-106-001', 'REQ-106-002', 'REQ-106-003']), dataclass(frozen=True) · Line 202

A source node that is not imported, with a machine-readable reason.

Attribute Type Default
reason str

Functions

Function Signature Summary
_code_entity_key _code_entity_key(props: dict[str, Any]) -> str | None Derive the canonical <relpath>::<qualname> id for the CodeFunction/ CodeClass trees, which carry file_path + name instead of an id.
_memory_key _memory_key(props: dict[str, Any]) -> str | None Session-scoped Memory identity <session_id>::<key> (ratified decision).
_run_testrun_key _run_testrun_key(props: dict[str, Any]) -> str | None Derived TestRun id for Run telemetry: <git_commit>::<timestamp>.
_name_key _name_key(props: dict[str, Any]) -> str | None Identity from a node's name (DesignModule/DesignClass carry no id).
_session_key traces _session_key(props: dict[str, Any]) -> str | None Session identity across its two real shapes: gateway/runtime sessions key on session_id; session-CLI work-sessions key on id.
_function_class_key _function_class_key(props: dict[str, Any]) -> str | None Unified id for the whole Function/Class tree: native Function/Class/ Method carry id; CodeFunction/CodeClass derive it from file_path::qualname; orphan {name, tag}…
_candidates _candidates(**names: str) -> Callable[[dict[str, Any]], str | None] Build a deriver that returns the first present, non-blank property among names — for labels whose identity lives under an entity-specific id (fact_id, spec_id, req_id …) rather than…
_event_key _event_key(props: dict[str, Any]) -> str | None Derived id for shapeless typed-events ({data, session_id?, timestamp}): <session_id>::<timestamp>, or just <timestamp> when there is no session.
resolve_canonical_label traces resolve_canonical_label(source_labels: list[str]) -> str | Skipped Pick the single canonical label for a node's set of source labels.
map_node traces map_node(source_labels: list[str], properties: dict[str, Any]) -> Mapped | Skipped Map one Neo4j node to its canonical form, or explain why it is skipped.
map_relationship traces map_relationship(rel_type: str) -> str Canonical relationship name: fold containment synonyms into DEFINES, preserve every other edge type as-is.
run_id_for_thread run_id_for_thread(thread_id: str) -> str The canonical PipelineRun id for a conversation thread.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/knowledge_graph/ontology_mapping.py

"""Graph → canonical-ontology mapping — full-fidelity, nothing discarded.

Maps every live graph label to its canonical label (:mod:`canonical_schema`).
Duplicate label trees *converge* (``CodeClass``→``Class``, ``CodeFunction``/
``Method``→``Function``, ``Specification``→``Spec``, ``Agent``→``Volk``,
``Run``→``TestRun``, ``WorkItem``→``Task``, ``TestNode``/``DLTest``→``Test``); the
operational events & messages (``CXPPacket``→``Message``, ``GatewayAuditEvent``→
``GatewayEvent``, governance/lifecycle events) are Tier H — NOT churn to discard,
but real, identifiable records imported at full fidelity.

Every node is identified: by its canonical key, an entity-specific id fallback
(``fact_id``/``spec_id``/…), or a derived id (code ``file_path::qualname``, event
``<session_id>::<timestamp>``). Pure — no I/O. The importer applies
:func:`map_node`; a node that genuinely cannot produce a key is reported (never
fabricated), matching ``canonical_schema.check``'s conformance stance.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.knowledge_graph import canonical_schema as cs

# Live graph label → canonical label, or ``None`` to EXCLUDE (operational churn
# that is NOT semantic knowledge and stays queryable in the graph). Every entry is
# grounded in the live 48-label census + the ratified ontology §4.
SOURCE_LABEL_MAP: dict[str, str | None] = {
    # ---- adopt: already the canonical label ---------------------------------
    "Module": cs.LABEL_MODULE,
    "Package": cs.LABEL_PACKAGE,
    "File": cs.LABEL_FILE,
    "Parameter": cs.LABEL_PARAMETER,
    "Rule": cs.LABEL_RULE,
    "Directory": cs.LABEL_DIRECTORY,
    "Predicate": cs.LABEL_PREDICATE,
    "Action": cs.LABEL_ACTION,
    "Portfolio": cs.LABEL_PORTFOLIO,
    "RequirementItem": cs.LABEL_REQUIREMENT_ITEM,
    "Class": cs.LABEL_CLASS,
    "Function": cs.LABEL_FUNCTION,
    "Project": cs.LABEL_PROJECT,
    "Intent": cs.LABEL_INTENT,
    "PipelineRun": cs.LABEL_PIPELINE_RUN,
    "Task": cs.LABEL_TASK,
    "Component": cs.LABEL_COMPONENT,
    "Artifact": cs.LABEL_ARTIFACT,
    "Requirement": cs.LABEL_REQUIREMENT,
    "Scenario": cs.LABEL_SCENARIO,
    "UserStory": cs.LABEL_USER_STORY,
    "Volk": cs.LABEL_VOLK,
    "Session": cs.LABEL_SESSION,
    # Thread (session-254 Option A, plans/thread-ontology-design.md): the
    # Tier-H correlation anchor every producer writes directly onto its
    # canonical `id` (`thread_entity_id`) -- adopt path, no KEY_DERIVATION.
    # Pre-existing gap: LABEL_THREAD was declared in canonical_schema.py
    # without ever being wired into this map, so it silently failed the
    # every-canonical-label-is-reachable completeness invariant below.
    "Thread": cs.LABEL_THREAD,
    "Decision": cs.LABEL_DECISION,
    "Pattern": cs.LABEL_PATTERN,
    "Fact": cs.LABEL_FACT,
    "Memory": cs.LABEL_MEMORY,
    "DesignModule": cs.LABEL_DESIGN_MODULE,
    "DesignClass": cs.LABEL_DESIGN_CLASS,
    "Architecture": cs.LABEL_ARCHITECTURE,
    "Test": cs.LABEL_TEST,
    "TestRun": cs.LABEL_TEST_RUN,
    "TestResult": cs.LABEL_TEST_RESULT,
    # Tier I — conversation capture (SPEC-021). The consumer writes the
    # canonical "id" directly (adopt path) — no KEY_DERIVATION needed.
    "Conversation": cs.LABEL_CONVERSATION,
    "ConversationTurn": cs.LABEL_CONVERSATION_TURN,
    # Tier J — architecture ontology (RATIFIED 2026-07-19, wave
    # ontology-phase1). ``architecture_writer.py`` (the converged writer that
    # supersedes ``sync_architecture_to_graph.py``'s pre-ontology shape) MERGEs
    # these 18 labels directly onto their canonical key (``id`` for the
    # project-scoped ones, ``name`` for the two it actually seeds globally —
    # WAFPillar/CloudService) — an adopt path, same as Module/Package/... above,
    # no KEY_DERIVATION needed. ``Change``/``Region`` are NOT writer outputs
    # this wave (Change has no upsert method yet; Region seeding is lazy/
    # deferred per ruling 6) and stay in ADDED_ONLY_LABELS below.
    "ApiContract": cs.LABEL_API_CONTRACT,
    "DataEntity": cs.LABEL_DATA_ENTITY,
    "QualityAttribute": cs.LABEL_QUALITY_ATTRIBUTE,
    "WAFPillar": cs.LABEL_WAF_PILLAR,
    "Constraint": cs.LABEL_CONSTRAINT,
    "CapacityModel": cs.LABEL_CAPACITY_MODEL,
    "CostModel": cs.LABEL_COST_MODEL,
    "CostLineItem": cs.LABEL_COST_LINE_ITEM,
    "Environment": cs.LABEL_ENVIRONMENT,
    "Deployment": cs.LABEL_DEPLOYMENT,
    "ArchitectureDecision": cs.LABEL_ARCHITECTURE_DECISION,
    "TradeOff": cs.LABEL_TRADE_OFF,
    "Risk": cs.LABEL_RISK,
    "FailureMode": cs.LABEL_FAILURE_MODE,
    "TechnologyChoice": cs.LABEL_TECHNOLOGY_CHOICE,
    "CloudService": cs.LABEL_CLOUD_SERVICE,
    "ServiceLevelObjective": cs.LABEL_SERVICE_LEVEL_OBJECTIVE,
    "Alert": cs.LABEL_ALERT,
    # ---- converge: duplicate label trees → the single canonical label -------
    "CodeClass": cs.LABEL_CLASS,
    "CodeFunction": cs.LABEL_FUNCTION,
    "Method": cs.LABEL_FUNCTION,  # methods are Function nodes (+ class_id prop)
    "Specification": cs.LABEL_SPEC,
    "Agent": cs.LABEL_VOLK,
    "Run": cs.LABEL_TEST_RUN,  # per decision: test-run telemetry → TestRun (derived key)
    "WorkItem": cs.LABEL_TASK,
    "TestNode": cs.LABEL_TEST,
    "DLTest": cs.LABEL_TEST,
    # ---- Tier H: operational events & messages (full-fidelity — real records,
    #      each identifiable by a natural id or a derived <session_id>::<timestamp>)
    "GatewayAuditEvent": cs.LABEL_GATEWAY_EVENT,
    "CXPPacket": cs.LABEL_MESSAGE,
    "AuditEntry": cs.LABEL_AUDIT_ENTRY,
    "Event": cs.LABEL_EVENT,
    "PolicyDecision": cs.LABEL_POLICY_DECISION,
    "Resolution": cs.LABEL_RESOLUTION,
    "Violation": cs.LABEL_VIOLATION,
    "Escalation": cs.LABEL_ESCALATION,
    "Timeout": cs.LABEL_TIMEOUT,
    "SessionStart": cs.LABEL_SESSION_START,
    "SessionEnd": cs.LABEL_SESSION_END,
    "ToolCall": cs.LABEL_TOOL_CALL,
}

# Canonical labels that are NOT sourced from the graph — they are ADDED by the
# importer from elsewhere: ``Tool`` from ``tools.registry`` and ``SemanticChunk``
# by unifying Memgraph's existing chunk labels. Every OTHER canonical label must
# be reachable as a target in SOURCE_LABEL_MAP (enforced by a completeness test).
#
# Tier J — architecture ontology (RATIFIED 2026-07-19,
# plans/architecture-ontology-draft.md, wave ontology-phase1): 18 of the 20
# Tier J labels are now ADOPTED above — ``architecture_writer.py`` (the
# converged writer, wave ontology-phase1) MERGEs them directly onto their
# canonical key, so they moved out of this set once that writer landed (this
# comment previously said "once that writer lands ... should be pulled out
# of this set then" — done). The two still here have no writer output yet:
# ``Change`` (no ``upsert_change`` method this wave — OpenSpec integration is
# a separate follow-up) and ``Region`` (seeding is explicitly lazy/deferred
# per ruling 6, not this wave).
#
# Tier K — factory fact-namespace control (SPEC-033 §4, B4a, ADR-0004):
# ``FactNamespace`` is never imported FROM Neo4j by this module -- it is
# written directly by ``factory.promotion.store.PromotionStore`` (the
# session-254 root-connectivity fix), so it has no ``SOURCE_LABEL_MAP`` entry
# and belongs here alongside Tool/SemanticChunk.
ADDED_ONLY_LABELS: frozenset[str] = frozenset(
    {
        cs.LABEL_TOOL,
        cs.LABEL_SEMANTIC_CHUNK,
        cs.LABEL_CHANGE,
        cs.LABEL_REGION,
        cs.LABEL_FACT_NAMESPACE,
        # ``Agent`` (session-254): written directly by
        # ``memory.cold_store``'s ``MERGE (v:Agent {alias: $volk_id})`` and
        # hub-linked ``(:Project)-[:HAS_AGENT]->``. UNLIKE ``FactNamespace``,
        # it DOES have a ``SOURCE_LABEL_MAP`` entry (``Agent`` -> ``Volk``), so
        # it is also a member of ``FOLDED_CANONICAL_LABELS`` below. It stays in
        # this set because the direct-write path is real; the earlier claim here
        # that it "has no SOURCE_LABEL_MAP entry" was false and is corrected.
        cs.LABEL_AGENT,
    }
)

# Canonical labels that are deliberately FOLDED AWAY on import: they appear in
# ``SOURCE_LABEL_MAP`` as KEYS pointing at a different canonical label, so they
# are never import TARGETS -- yet they are canonical, because they exist live
# with their own UNIQUE constraint and node population (measured 2026-08-06:
# 95,176 ``CXPPacket`` nodes). This is transitional duplication: the converged
# twin (``Message``/``Function``) is the write target for new data, while the
# folded label stays canonical until its nodes are migrated. Distinct from
# ADDED_ONLY_LABELS, which is "no SOURCE_LABEL_MAP entry at all because another
# writer owns the label" -- these DO have an entry, pointing elsewhere.
#
# Derived, never hand-listed: an entry can only appear here by actually being a
# canonical label that the map folds into a DIFFERENT canonical label. That
# makes the exemption self-verifying -- it cannot be widened to hide a label
# that was simply forgotten, which is how a "reachability" gate goes false-green.
FOLDED_CANONICAL_LABELS: frozenset[str] = frozenset(
    source
    for source, target in SOURCE_LABEL_MAP.items()
    if source in cs.CANONICAL_LABELS and target is not None and target != source
)


@traces(spec="106", reqs=["REQ-106-001", "REQ-106-002", "REQ-106-003"])
@dataclass(frozen=True)
class Mapped:
    """A source node resolved to its canonical shape, ready to MERGE."""

    label: str
    key: str
    key_value: str
    properties: dict[str, Any] = field(default_factory=dict)


@traces(spec="106", reqs=["REQ-106-001", "REQ-106-002", "REQ-106-003"])
@dataclass(frozen=True)
class Skipped:
    """A source node that is not imported, with a machine-readable reason.

    ``reason`` is one of: ``"unmapped:<labels>"`` (no rule for any label),
    ``"excluded:<label>"`` (a deliberate churn exclusion), ``"ambiguous:<a|b>"``
    (labels map to two different canonical types — a real modelling smell), or
    ``"missing-key:<key>"`` (the canonical merge key is absent/underivable — a
    data-quality finding the importer surfaces).
    """

    reason: str


def _code_entity_key(props: dict[str, Any]) -> str | None:
    """Derive the canonical ``<relpath>::<qualname>`` id for the CodeFunction/
    CodeClass trees, which carry ``file_path`` + ``name`` instead of an ``id``."""
    relpath = props.get("file_path") or props.get("module_path") or props.get("module")
    name = props.get("name")
    if not relpath or not name:
        return None
    class_name = props.get("class_name")
    qualname = f"{class_name}.{name}" if class_name else str(name)
    try:
        return cs.code_entity_id(str(relpath), qualname)
    except ValueError:
        return None


def _memory_key(props: dict[str, Any]) -> str | None:
    """Session-scoped Memory identity ``<session_id>::<key>`` (ratified decision)."""
    session_id = props.get("session_id")
    key = props.get("key")
    if not session_id or not key:
        return None
    return f"{session_id}::{key}"


def _run_testrun_key(props: dict[str, Any]) -> str | None:
    """Derived TestRun id for Run telemetry: ``<git_commit>::<timestamp>``."""
    commit = props.get("git_commit")
    timestamp = props.get("timestamp")
    if not commit or timestamp is None:
        return None
    return f"{commit}::{timestamp}"


def _name_key(props: dict[str, Any]) -> str | None:
    """Identity from a node's ``name`` (DesignModule/DesignClass carry no id)."""
    name = props.get("name")
    return str(name) if name else None


@traces(spec="106", story="FR-001", reqs=["REQ-106-003"])
def _session_key(props: dict[str, Any]) -> str | None:
    """Session identity across its two real shapes: gateway/runtime sessions key
    on ``session_id``; session-CLI work-sessions key on ``id``. EVERY real session
    imports — the bare ``{session_id}`` gateway sessions are NOT stubs; they are
    real connections (spanning weeks of audit-stamped activity), just recorded
    with only their id."""
    value = props.get("session_id") or props.get("id")
    return str(value) if value else None


def _function_class_key(props: dict[str, Any]) -> str | None:
    """Unified id for the whole Function/Class tree: native ``Function``/``Class``/
    ``Method`` carry ``id``; ``CodeFunction``/``CodeClass`` derive it from
    ``file_path::qualname``; orphan ``{name, tag}`` nodes fall back to ``name``."""
    existing = props.get("id")
    if existing is not None and str(existing).strip():
        return str(existing)
    derived = _code_entity_key(props)
    if derived is not None:
        return derived
    name = props.get("name")
    return str(name) if name else None


def _candidates(*names: str) -> Callable[[dict[str, Any]], str | None]:
    """Build a deriver that returns the first present, non-blank property among
    ``names`` — for labels whose identity lives under an entity-specific id
    (``fact_id``, ``spec_id``, ``req_id`` …) rather than the canonical ``id``.
    No node with a real identity is dropped just because it uses its own id name.
    """

    @traces(spec="106", reqs=["REQ-106-002", "REQ-106-003"])
    def derive(props: dict[str, Any]) -> str | None:
        for name in names:
            value = props.get(name)
            if value is not None and str(value).strip():
                return str(value)
        return None

    return derive


def _event_key(props: dict[str, Any]) -> str | None:
    """Derived id for shapeless typed-events ({data, session_id?, timestamp}):
    ``<session_id>::<timestamp>``, or just ``<timestamp>`` when there is no
    session. The (session, microsecond-timestamp) pair is unique per event."""
    timestamp = props.get("timestamp")
    if timestamp is None or not str(timestamp).strip():
        return None
    session_id = props.get("session_id")
    return f"{session_id}::{timestamp}" if session_id else str(timestamp)


# SOURCE label -> function deriving the canonical key value, for labels whose
# identity lives under a different/derived property than the canonical merge key.
# (Absent -> read the canonical key straight from the node's properties.)
KEY_DERIVATION: dict[str, Callable[[dict[str, Any]], str | None]] = {
    # Whole Function/Class tree: id | file_path::qualname | name (orphans).
    "Function": _function_class_key,
    "Class": _function_class_key,
    "CodeFunction": _function_class_key,
    "CodeClass": _function_class_key,
    "Method": _function_class_key,
    "Session": _session_key,
    "Memory": _memory_key,
    "Run": _run_testrun_key,
    "DesignModule": _name_key,
    "DesignClass": _name_key,
    # Entity-specific id fallbacks (the keyless nodes are identifiable):
    "Fact": _candidates("id", "fact_id"),
    "Specification": _candidates("id", "spec_id"),
    "Requirement": _candidates("id", "req_id"),
    "UserStory": _candidates("id", "story_id"),
    "TestRun": _candidates("id", "run_id"),
    "TestResult": _candidates("id", "result_id", "name"),
    "Project": _candidates("slug", "name"),
    "Volk": _candidates("alias", "name"),
    "Rule": _candidates("id", "rule_id", "name"),
    "Architecture": _candidates("id", "name"),
    # Tier H shapeless typed-events -> derived <session_id>::<timestamp> id.
    # (Message/GatewayAuditEvent/AuditEntry/Resolution carry a natural key —
    # message_id/entry_id/resolution_id — so they need no derivation.)
    "PolicyDecision": _event_key,
    "Event": _event_key,
    "ToolCall": _event_key,
    "Violation": _event_key,
    "Timeout": _event_key,
    "Escalation": _event_key,
    "SessionStart": _event_key,
    "SessionEnd": _event_key,
}


@traces(spec="106", story="FR-001", reqs=["REQ-106-001"])
def resolve_canonical_label(source_labels: list[str]) -> str | Skipped:
    """Pick the single canonical label for a node's set of source labels.

    Excluded labels are ignored in favour of any canonical mapping; a node whose
    labels resolve to two *different* canonical types is ``ambiguous`` (reported,
    not guessed).
    """
    known = [lbl for lbl in source_labels if lbl in SOURCE_LABEL_MAP]
    target_set: set[str] = set()
    for lbl in known:
        target = SOURCE_LABEL_MAP[lbl]
        if target is not None:
            target_set.add(target)
    targets = sorted(target_set)
    if len(targets) == 1:
        return targets[0]
    if len(targets) > 1:
        return Skipped(reason="ambiguous:" + "|".join(targets))
    if not known:
        return Skipped(reason="unmapped:" + "|".join(sorted(source_labels)))
    # Every known label mapped to None → a deliberate churn exclusion.
    return Skipped(reason="excluded:" + "|".join(sorted(known)))


@traces(spec="106", reqs=["REQ-106-001", "REQ-106-002", "REQ-106-003"])
def map_node(source_labels: list[str], properties: dict[str, Any]) -> Mapped | Skipped:
    """Map one Neo4j node to its canonical form, or explain why it is skipped.

    The canonical merge key comes from ``canonical_schema.CANONICAL_MERGE_KEYS``.
    Its value is produced by the source label's :data:`KEY_DERIVATION` function
    when the identity lives under a different/derived property (code entities,
    Memory, Run, design nodes), otherwise read straight from ``properties``. A
    :data:`NODE_FILTER` may reject a node first (e.g. bare Session stubs). A
    missing/blank/underivable key yields a ``missing-key`` :class:`Skipped` — the
    node is never given a fabricated id.
    """
    resolved = resolve_canonical_label(source_labels)
    if isinstance(resolved, Skipped):
        return resolved
    label = resolved
    # The source label(s) that drove the resolution carry any key derivation.
    drivers = sorted(lbl for lbl in source_labels if SOURCE_LABEL_MAP.get(lbl) == label)
    key = cs.CANONICAL_MERGE_KEYS[label]
    value: str | None = None
    for driver in drivers:
        derive = KEY_DERIVATION.get(driver)
        if derive is not None:
            value = derive(properties)
            break
    if value is None:
        raw = properties.get(key)
        if raw is not None and not (isinstance(raw, str) and not raw.strip()):
            value = str(raw)
    if value is None or not value.strip():
        return Skipped(reason=f"missing-key:{key}")
    # The mapped properties must carry the CANONICAL key value (stringified /
    # derived), not the raw source one: importers MERGE on ``key_value`` and
    # then ``SET n += properties`` — a raw non-string ``id`` in properties
    # would clobber the merge key with a different type (two integer-id :Test
    # nodes reached the live graph this way and poisoned every STARTS WITH
    # scan, 2026-07-08).
    normalized = dict(properties)
    normalized[key] = value
    return Mapped(label=label, key=key, key_value=value, properties=normalized)


# ---------------------------------------------------------------------------
# Relationship-type ontology.
# ---------------------------------------------------------------------------
# The live graph's 33 edge types are mostly already distinct and meaningful —
# DURING, ROUTED_TO, ABOUT, BELONGS_TO, IN_SESSION, TRIGGERED, CALLS, etc. all
# carry different semantics and are PRESERVED, never collapsed. The only mandated
# convergence is the writer-specific containment synonyms (HAS_CLASS/HAS_FUNCTION/
# HAS_METHOD) into the single canonical DEFINES (canonical_schema convention).
# Endpoint labels converge automatically via map_node (Method->Function, Agent->
# Volk, CodeClass->Class, ...), so e.g. (Class)-[:HAS_METHOD]->(Method) imports
# as the canonical (Class)-[:DEFINES]->(Function).
REL_MAP: dict[str, str] = {
    "HAS_CLASS": cs.REL_DEFINES,
    "HAS_FUNCTION": cs.REL_DEFINES,
    "HAS_METHOD": cs.REL_DEFINES,
}


@traces(spec="106", reqs=["REQ-106-004"])
def map_relationship(rel_type: str) -> str:
    """Canonical relationship name: fold containment synonyms into DEFINES,
    preserve every other edge type as-is."""
    return REL_MAP.get(rel_type, rel_type)


# ---------------------------------------------------------------------------
# Derived provenance — thread_id -> pipeline run.
# ---------------------------------------------------------------------------
# A node carrying a ``thread_id`` belongs to the pipeline run whose id is
# ``run:<thread_id>`` (Intents already assert this via TRIGGERED; this extends the
# SAME faithful link to that run's Messages and Artifacts). This is not
# fabrication: the run's id and the node's thread_id encode the identical thread.
REL_IN_RUN = "IN_RUN"

# Source labels whose thread_id grounds them in their run (Intent is excluded — it
# already carries a native TRIGGERED edge to its PipelineRun).
THREAD_PROVENANCE_LABELS: tuple[str, ...] = ("CXPPacket", "Artifact")


def run_id_for_thread(thread_id: str) -> str:
    """The canonical PipelineRun id for a conversation thread."""
    return f"run:{thread_id}"