Skip to content

Core

The generic knowledge-graph core.

Typed :class:~ideagraph.kg.node.Node objects connected by directed :class:~ideagraph.kg.edge.Edge objects in a :class:~ideagraph.kg.graph.KnowledgeGraph, with a :class:~ideagraph.kg.profile.Profile supplying the vocabulary and rules for a given domain. Importing this package registers the built-in profiles.

Diagnostic dataclass

A single validation finding.

Attributes:

Name Type Description
level str

"error", "warning", or "info".

code str

A stable machine-readable code.

message str

A human-readable, self-correction-friendly description.

ref str | None

The id of the offending node or edge, if any.

Source code in src/ideagraph/kg/profile.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@dataclass(frozen=True)
class Diagnostic:
    """A single validation finding.

    Attributes:
        level: ``"error"``, ``"warning"``, or ``"info"``.
        code: A stable machine-readable code.
        message: A human-readable, self-correction-friendly description.
        ref: The id of the offending node or edge, if any.
    """

    level: str
    code: str
    message: str
    ref: str | None = None

    def to_dict(self) -> dict[str, Any]:
        """Serialise the diagnostic.

        Returns:
            A dictionary representation.

        """
        return {"level": self.level, "code": self.code, "message": self.message, "ref": self.ref}

to_dict()

Serialise the diagnostic.

Returns:

Type Description
dict[str, Any]

A dictionary representation.

Source code in src/ideagraph/kg/profile.py
40
41
42
43
44
45
46
47
def to_dict(self) -> dict[str, Any]:
    """Serialise the diagnostic.

    Returns:
        A dictionary representation.

    """
    return {"level": self.level, "code": self.code, "message": self.message, "ref": self.ref}

Edge dataclass

A directed, typed connection between two nodes.

Attributes:

Name Type Description
type str

The edge's type, from the active profile's edge vocabulary.

source str

The id of the source node.

target str

The id of the target node (a local id, or a global article_id#node_id address for a cross-article edge).

id str

Stable unique identifier (UUID4 hex if not supplied).

properties dict[str, Any]

Arbitrary structured metadata about the connection.

created_at datetime

Timezone-aware creation timestamp (UTC).

Source code in src/ideagraph/kg/edge.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@dataclass
class Edge:
    """A directed, typed connection between two nodes.

    Attributes:
        type: The edge's type, from the active profile's edge vocabulary.
        source: The id of the source node.
        target: The id of the target node (a local id, or a global
            ``article_id#node_id`` address for a cross-article edge).
        id: Stable unique identifier (UUID4 hex if not supplied).
        properties: Arbitrary structured metadata about the connection.
        created_at: Timezone-aware creation timestamp (UTC).
    """

    type: str
    source: str
    target: str
    id: str = field(default_factory=lambda: uuid4().hex)
    properties: dict[str, Any] = field(default_factory=dict)
    created_at: datetime = field(default_factory=_utcnow)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the edge to a JSON-compatible dictionary.

        Returns:
            A dictionary representation of the edge.

        """
        return {
            "id": self.id,
            "type": self.type,
            "source": self.source,
            "target": self.target,
            "properties": dict(self.properties),
            "created_at": self.created_at.isoformat(),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Edge:
        """Reconstruct an edge from its dictionary representation.

        Args:
            data: A dictionary as produced by :meth:`to_dict`.

        Returns:
            The reconstructed edge.

        Raises:
            KeyError: If ``type``, ``source``, or ``target`` is missing.

        """
        kwargs: dict[str, Any] = {
            "type": data["type"],
            "source": data["source"],
            "target": data["target"],
        }
        if "id" in data:
            kwargs["id"] = data["id"]
        if data.get("properties") is not None:
            kwargs["properties"] = dict(data["properties"])
        if data.get("created_at") is not None:
            kwargs["created_at"] = datetime.fromisoformat(data["created_at"])
        return cls(**kwargs)

from_dict(data) classmethod

Reconstruct an edge from its dictionary representation.

Parameters:

Name Type Description Default
data dict[str, Any]

A dictionary as produced by :meth:to_dict.

required

Returns:

Type Description
Edge

The reconstructed edge.

Raises:

Type Description
KeyError

If type, source, or target is missing.

Source code in src/ideagraph/kg/edge.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Edge:
    """Reconstruct an edge from its dictionary representation.

    Args:
        data: A dictionary as produced by :meth:`to_dict`.

    Returns:
        The reconstructed edge.

    Raises:
        KeyError: If ``type``, ``source``, or ``target`` is missing.

    """
    kwargs: dict[str, Any] = {
        "type": data["type"],
        "source": data["source"],
        "target": data["target"],
    }
    if "id" in data:
        kwargs["id"] = data["id"]
    if data.get("properties") is not None:
        kwargs["properties"] = dict(data["properties"])
    if data.get("created_at") is not None:
        kwargs["created_at"] = datetime.fromisoformat(data["created_at"])
    return cls(**kwargs)

to_dict()

Serialise the edge to a JSON-compatible dictionary.

Returns:

Type Description
dict[str, Any]

A dictionary representation of the edge.

Source code in src/ideagraph/kg/edge.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def to_dict(self) -> dict[str, Any]:
    """Serialise the edge to a JSON-compatible dictionary.

    Returns:
        A dictionary representation of the edge.

    """
    return {
        "id": self.id,
        "type": self.type,
        "source": self.source,
        "target": self.target,
        "properties": dict(self.properties),
        "created_at": self.created_at.isoformat(),
    }

EdgeRule dataclass

Schema for an edge type.

Attributes:

Name Type Description
type str

The edge type name.

source_types frozenset[str]

Allowed source node types (empty = any).

target_types frozenset[str]

Allowed target node types (empty = any). Ignored for cross-article targets, whose node lives in another graph.

required_properties frozenset[str]

Property keys every edge of this type must set.

Source code in src/ideagraph/kg/profile.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@dataclass(frozen=True)
class EdgeRule:
    """Schema for an edge type.

    Attributes:
        type: The edge type name.
        source_types: Allowed source node types (empty = any).
        target_types: Allowed target node types (empty = any). Ignored for
            cross-article targets, whose node lives in another graph.
        required_properties: Property keys every edge of this type must set.
    """

    type: str
    source_types: frozenset[str] = frozenset()
    target_types: frozenset[str] = frozenset()
    required_properties: frozenset[str] = frozenset()

KnowledgeGraph dataclass

An in-memory collection of typed nodes and the edges between them.

Attributes:

Name Type Description
nodes dict[str, Node]

Nodes held by the graph, keyed by id.

edges dict[str, Edge]

Edges held by the graph, keyed by id.

article_id str | None

This graph's stable article id. Nodes are addressed globally as article_id#node_id; required before another graph can reference this one.

metadata dict[str, Any]

Arbitrary graph-level metadata (e.g. title).

Source code in src/ideagraph/kg/graph.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
@dataclass
class KnowledgeGraph:
    """An in-memory collection of typed nodes and the edges between them.

    Attributes:
        nodes: Nodes held by the graph, keyed by id.
        edges: Edges held by the graph, keyed by id.
        article_id: This graph's stable article id. Nodes are addressed globally
            as ``article_id#node_id``; required before another graph can
            reference this one.
        metadata: Arbitrary graph-level metadata (e.g. ``title``).
    """

    nodes: dict[str, Node] = field(default_factory=dict)
    edges: dict[str, Edge] = field(default_factory=dict)
    article_id: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)
    _out: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list), repr=False, compare=False)
    _in: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list), repr=False, compare=False)

    def add_node(self, node: Node) -> Node:
        """Add or replace a node.

        Args:
            node: The node to store.

        Returns:
            The stored node.

        """
        self.nodes[node.id] = node
        return node

    def add_edge(self, edge: Edge) -> Edge:
        """Add or replace an edge and update the traversal index.

        Args:
            edge: The edge to store.

        Returns:
            The stored edge.

        """
        if edge.id in self.edges:
            self._deindex(self.edges[edge.id])
        self.edges[edge.id] = edge
        self._out[edge.source].append(edge.id)
        self._in[edge.target].append(edge.id)
        return edge

    def _deindex(self, edge: Edge) -> None:
        """Remove an edge from the traversal index.

        Args:
            edge: The edge to remove from the index.

        """
        out_ids = self._out.get(edge.source)
        if out_ids and edge.id in out_ids:
            out_ids.remove(edge.id)
        in_ids = self._in.get(edge.target)
        if in_ids and edge.id in in_ids:
            in_ids.remove(edge.id)

    def outgoing(self, node_id: str, edge_type: str | None = None) -> list[Edge]:
        """Return edges whose source is ``node_id``.

        Args:
            node_id: The id of the source node.
            edge_type: If given, only edges of this type are returned.

        Returns:
            The matching edges, in insertion order.

        """
        edges = [self.edges[eid] for eid in self._out.get(node_id, [])]
        if edge_type is not None:
            edges = [e for e in edges if e.type == edge_type]
        return edges

    def incoming(self, node_id: str, edge_type: str | None = None) -> list[Edge]:
        """Return edges whose target is ``node_id``.

        Args:
            node_id: The id of the target node.
            edge_type: If given, only edges of this type are returned.

        Returns:
            The matching edges, in insertion order.

        """
        edges = [self.edges[eid] for eid in self._in.get(node_id, [])]
        if edge_type is not None:
            edges = [e for e in edges if e.type == edge_type]
        return edges

    def nodes_of_type(self, node_type: str) -> list[Node]:
        """Return all nodes of a given type, in insertion order.

        Args:
            node_type: The node type to filter by.

        Returns:
            The matching nodes.

        """
        return [n for n in self.nodes.values() if n.type == node_type]

    def global_id(self, node_id: str) -> str:
        """Return the global address ``article_id#node_id`` for a local node.

        Args:
            node_id: A local node id.

        Returns:
            The global address.

        Raises:
            ValueError: If this graph has no ``article_id`` set.

        """
        if self.article_id is None:
            raise ValueError("graph has no article_id; set one before building global ids")
        return _global_id(self.article_id, node_id)

    def to_dict(self) -> dict[str, Any]:
        """Serialise the whole graph to a JSON-compatible dictionary.

        Returns:
            A dictionary with ``article_id``, ``metadata``, ``nodes``, and
            ``edges``.

        """
        return {
            "article_id": self.article_id,
            "metadata": dict(self.metadata),
            "nodes": [n.to_dict() for n in self.nodes.values()],
            "edges": [e.to_dict() for e in self.edges.values()],
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> KnowledgeGraph:
        """Reconstruct a graph from its dictionary representation.

        Args:
            data: A dictionary as produced by :meth:`to_dict`.

        Returns:
            The reconstructed graph, with its traversal index rebuilt.

        """
        graph = cls()
        graph.article_id = data.get("article_id")
        if data.get("metadata") is not None:
            graph.metadata = dict(data["metadata"])
        for n in data.get("nodes", []):
            graph.add_node(Node.from_dict(n))
        for e in data.get("edges", []):
            graph.add_edge(Edge.from_dict(e))
        return graph

add_edge(edge)

Add or replace an edge and update the traversal index.

Parameters:

Name Type Description Default
edge Edge

The edge to store.

required

Returns:

Type Description
Edge

The stored edge.

Source code in src/ideagraph/kg/graph.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def add_edge(self, edge: Edge) -> Edge:
    """Add or replace an edge and update the traversal index.

    Args:
        edge: The edge to store.

    Returns:
        The stored edge.

    """
    if edge.id in self.edges:
        self._deindex(self.edges[edge.id])
    self.edges[edge.id] = edge
    self._out[edge.source].append(edge.id)
    self._in[edge.target].append(edge.id)
    return edge

add_node(node)

Add or replace a node.

Parameters:

Name Type Description Default
node Node

The node to store.

required

Returns:

Type Description
Node

The stored node.

Source code in src/ideagraph/kg/graph.py
42
43
44
45
46
47
48
49
50
51
52
53
def add_node(self, node: Node) -> Node:
    """Add or replace a node.

    Args:
        node: The node to store.

    Returns:
        The stored node.

    """
    self.nodes[node.id] = node
    return node

from_dict(data) classmethod

Reconstruct a graph from its dictionary representation.

Parameters:

Name Type Description Default
data dict[str, Any]

A dictionary as produced by :meth:to_dict.

required

Returns:

Type Description
KnowledgeGraph

The reconstructed graph, with its traversal index rebuilt.

Source code in src/ideagraph/kg/graph.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
@classmethod
def from_dict(cls, data: dict[str, Any]) -> KnowledgeGraph:
    """Reconstruct a graph from its dictionary representation.

    Args:
        data: A dictionary as produced by :meth:`to_dict`.

    Returns:
        The reconstructed graph, with its traversal index rebuilt.

    """
    graph = cls()
    graph.article_id = data.get("article_id")
    if data.get("metadata") is not None:
        graph.metadata = dict(data["metadata"])
    for n in data.get("nodes", []):
        graph.add_node(Node.from_dict(n))
    for e in data.get("edges", []):
        graph.add_edge(Edge.from_dict(e))
    return graph

global_id(node_id)

Return the global address article_id#node_id for a local node.

Parameters:

Name Type Description Default
node_id str

A local node id.

required

Returns:

Type Description
str

The global address.

Raises:

Type Description
ValueError

If this graph has no article_id set.

Source code in src/ideagraph/kg/graph.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def global_id(self, node_id: str) -> str:
    """Return the global address ``article_id#node_id`` for a local node.

    Args:
        node_id: A local node id.

    Returns:
        The global address.

    Raises:
        ValueError: If this graph has no ``article_id`` set.

    """
    if self.article_id is None:
        raise ValueError("graph has no article_id; set one before building global ids")
    return _global_id(self.article_id, node_id)

incoming(node_id, edge_type=None)

Return edges whose target is node_id.

Parameters:

Name Type Description Default
node_id str

The id of the target node.

required
edge_type str | None

If given, only edges of this type are returned.

None

Returns:

Type Description
list[Edge]

The matching edges, in insertion order.

Source code in src/ideagraph/kg/graph.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def incoming(self, node_id: str, edge_type: str | None = None) -> list[Edge]:
    """Return edges whose target is ``node_id``.

    Args:
        node_id: The id of the target node.
        edge_type: If given, only edges of this type are returned.

    Returns:
        The matching edges, in insertion order.

    """
    edges = [self.edges[eid] for eid in self._in.get(node_id, [])]
    if edge_type is not None:
        edges = [e for e in edges if e.type == edge_type]
    return edges

nodes_of_type(node_type)

Return all nodes of a given type, in insertion order.

Parameters:

Name Type Description Default
node_type str

The node type to filter by.

required

Returns:

Type Description
list[Node]

The matching nodes.

Source code in src/ideagraph/kg/graph.py
118
119
120
121
122
123
124
125
126
127
128
def nodes_of_type(self, node_type: str) -> list[Node]:
    """Return all nodes of a given type, in insertion order.

    Args:
        node_type: The node type to filter by.

    Returns:
        The matching nodes.

    """
    return [n for n in self.nodes.values() if n.type == node_type]

outgoing(node_id, edge_type=None)

Return edges whose source is node_id.

Parameters:

Name Type Description Default
node_id str

The id of the source node.

required
edge_type str | None

If given, only edges of this type are returned.

None

Returns:

Type Description
list[Edge]

The matching edges, in insertion order.

Source code in src/ideagraph/kg/graph.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def outgoing(self, node_id: str, edge_type: str | None = None) -> list[Edge]:
    """Return edges whose source is ``node_id``.

    Args:
        node_id: The id of the source node.
        edge_type: If given, only edges of this type are returned.

    Returns:
        The matching edges, in insertion order.

    """
    edges = [self.edges[eid] for eid in self._out.get(node_id, [])]
    if edge_type is not None:
        edges = [e for e in edges if e.type == edge_type]
    return edges

to_dict()

Serialise the whole graph to a JSON-compatible dictionary.

Returns:

Type Description
dict[str, Any]

A dictionary with article_id, metadata, nodes, and

dict[str, Any]

edges.

Source code in src/ideagraph/kg/graph.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def to_dict(self) -> dict[str, Any]:
    """Serialise the whole graph to a JSON-compatible dictionary.

    Returns:
        A dictionary with ``article_id``, ``metadata``, ``nodes``, and
        ``edges``.

    """
    return {
        "article_id": self.article_id,
        "metadata": dict(self.metadata),
        "nodes": [n.to_dict() for n in self.nodes.values()],
        "edges": [e.to_dict() for e in self.edges.values()],
    }

Node dataclass

A single piece of information in a knowledge graph.

Attributes:

Name Type Description
type str

The node's type, from the active profile's vocabulary.

text str

The human-readable content of the node.

id str

Stable unique identifier (UUID4 hex if not supplied).

tags list[str]

Free-form labels for grouping and discovery.

properties dict[str, Any]

Arbitrary structured, domain-specific fields.

created_at datetime

Timezone-aware creation timestamp (UTC).

updated_at datetime

Timezone-aware timestamp of the last change (UTC).

Source code in src/ideagraph/kg/node.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@dataclass
class Node:
    """A single piece of information in a knowledge graph.

    Attributes:
        type: The node's type, from the active profile's vocabulary.
        text: The human-readable content of the node.
        id: Stable unique identifier (UUID4 hex if not supplied).
        tags: Free-form labels for grouping and discovery.
        properties: Arbitrary structured, domain-specific fields.
        created_at: Timezone-aware creation timestamp (UTC).
        updated_at: Timezone-aware timestamp of the last change (UTC).
    """

    type: str
    text: str = ""
    id: str = field(default_factory=lambda: uuid4().hex)
    tags: list[str] = field(default_factory=list)
    properties: dict[str, Any] = field(default_factory=dict)
    created_at: datetime = field(default_factory=_utcnow)
    updated_at: datetime = field(default_factory=_utcnow)

    def touch(self) -> None:
        """Refresh :attr:`updated_at` to now."""
        self.updated_at = _utcnow()

    def to_dict(self) -> dict[str, Any]:
        """Serialise the node to a JSON-compatible dictionary.

        Returns:
            A dictionary representation of the node.

        """
        return {
            "id": self.id,
            "type": self.type,
            "text": self.text,
            "tags": list(self.tags),
            "properties": dict(self.properties),
            "created_at": self.created_at.isoformat(),
            "updated_at": self.updated_at.isoformat(),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Node:
        """Reconstruct a node from its dictionary representation.

        Args:
            data: A dictionary as produced by :meth:`to_dict`.

        Returns:
            The reconstructed node.

        Raises:
            KeyError: If ``type`` is missing.

        """
        kwargs: dict[str, Any] = {"type": data["type"], "text": data.get("text", "")}
        if "id" in data:
            kwargs["id"] = data["id"]
        if data.get("tags") is not None:
            kwargs["tags"] = list(data["tags"])
        if data.get("properties") is not None:
            kwargs["properties"] = dict(data["properties"])
        if data.get("created_at") is not None:
            kwargs["created_at"] = datetime.fromisoformat(data["created_at"])
        if data.get("updated_at") is not None:
            kwargs["updated_at"] = datetime.fromisoformat(data["updated_at"])
        return cls(**kwargs)

from_dict(data) classmethod

Reconstruct a node from its dictionary representation.

Parameters:

Name Type Description Default
data dict[str, Any]

A dictionary as produced by :meth:to_dict.

required

Returns:

Type Description
Node

The reconstructed node.

Raises:

Type Description
KeyError

If type is missing.

Source code in src/ideagraph/kg/node.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Node:
    """Reconstruct a node from its dictionary representation.

    Args:
        data: A dictionary as produced by :meth:`to_dict`.

    Returns:
        The reconstructed node.

    Raises:
        KeyError: If ``type`` is missing.

    """
    kwargs: dict[str, Any] = {"type": data["type"], "text": data.get("text", "")}
    if "id" in data:
        kwargs["id"] = data["id"]
    if data.get("tags") is not None:
        kwargs["tags"] = list(data["tags"])
    if data.get("properties") is not None:
        kwargs["properties"] = dict(data["properties"])
    if data.get("created_at") is not None:
        kwargs["created_at"] = datetime.fromisoformat(data["created_at"])
    if data.get("updated_at") is not None:
        kwargs["updated_at"] = datetime.fromisoformat(data["updated_at"])
    return cls(**kwargs)

to_dict()

Serialise the node to a JSON-compatible dictionary.

Returns:

Type Description
dict[str, Any]

A dictionary representation of the node.

Source code in src/ideagraph/kg/node.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def to_dict(self) -> dict[str, Any]:
    """Serialise the node to a JSON-compatible dictionary.

    Returns:
        A dictionary representation of the node.

    """
    return {
        "id": self.id,
        "type": self.type,
        "text": self.text,
        "tags": list(self.tags),
        "properties": dict(self.properties),
        "created_at": self.created_at.isoformat(),
        "updated_at": self.updated_at.isoformat(),
    }

touch()

Refresh :attr:updated_at to now.

Source code in src/ideagraph/kg/node.py
50
51
52
def touch(self) -> None:
    """Refresh :attr:`updated_at` to now."""
    self.updated_at = _utcnow()

NodeRule dataclass

Schema for a node type.

Attributes:

Name Type Description
type str

The node type name.

required_properties frozenset[str]

Property keys every node of this type must set.

Source code in src/ideagraph/kg/profile.py
50
51
52
53
54
55
56
57
58
59
60
@dataclass(frozen=True)
class NodeRule:
    """Schema for a node type.

    Attributes:
        type: The node type name.
        required_properties: Property keys every node of this type must set.
    """

    type: str
    required_properties: frozenset[str] = frozenset()

Profile dataclass

A named schema of node and edge types plus structural rules.

Attributes:

Name Type Description
name str

The profile's stable name.

node_types dict[str, NodeRule]

Mapping of node type name to its rule.

edge_types dict[str, EdgeRule]

Mapping of edge type name to its rule.

Source code in src/ideagraph/kg/profile.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
@dataclass(frozen=True)
class Profile:
    """A named schema of node and edge types plus structural rules.

    Attributes:
        name: The profile's stable name.
        node_types: Mapping of node type name to its rule.
        edge_types: Mapping of edge type name to its rule.
    """

    name: str
    node_types: dict[str, NodeRule] = field(default_factory=dict)
    edge_types: dict[str, EdgeRule] = field(default_factory=dict)

    def allows_node_type(self, node_type: str) -> bool:
        """Report whether a node type is part of this profile.

        Args:
            node_type: The node type to check.

        Returns:
            ``True`` if the type is known.

        """
        return node_type in self.node_types

    def allows_edge_type(self, edge_type: str) -> bool:
        """Report whether an edge type is part of this profile.

        Args:
            edge_type: The edge type to check.

        Returns:
            ``True`` if the type is known.

        """
        return edge_type in self.edge_types

    def validate(self, graph: KnowledgeGraph) -> list[Diagnostic]:
        """Structurally validate a graph against this profile.

        Checks that every node/edge type is known, that required properties are
        present, that edges reference existing source nodes, and that endpoint
        types satisfy the edge rule. Cross-article targets (global ids) are not
        resolved here — that is a library-level concern.

        Args:
            graph: The graph to validate.

        Returns:
            A list of diagnostics (empty if the graph conforms).

        """
        out: list[Diagnostic] = []
        for node in graph.nodes.values():
            rule = self.node_types.get(node.type)
            if rule is None:
                out.append(Diagnostic("error", "unknown-node-type", f"unknown node type {node.type!r}", node.id))
                continue
            for prop in sorted(rule.required_properties - set(node.properties)):
                out.append(Diagnostic("error", "missing-property", f"node missing property {prop!r}", node.id))
        for edge in graph.edges.values():
            out.extend(self._validate_edge(graph, edge))
        return out

    def _validate_edge(self, graph: KnowledgeGraph, edge: Any) -> list[Diagnostic]:
        """Validate a single edge against its rule.

        Args:
            graph: The graph the edge belongs to.
            edge: The edge to validate.

        Returns:
            Diagnostics for this edge.

        """
        rule = self.edge_types.get(edge.type)
        if rule is None:
            return [Diagnostic("error", "unknown-edge-type", f"unknown edge type {edge.type!r}", edge.id)]
        out: list[Diagnostic] = []
        for prop in sorted(rule.required_properties - set(edge.properties)):
            out.append(Diagnostic("error", "missing-property", f"edge missing property {prop!r}", edge.id))
        source = graph.nodes.get(edge.source)
        if source is None:
            out.append(Diagnostic("error", "edge-dangling-source", f"edge source {edge.source!r} not found", edge.id))
        elif rule.source_types and source.type not in rule.source_types:
            out.append(
                Diagnostic("error", "edge-bad-source-type", f"{edge.type!r} source may not be {source.type!r}", edge.id)
            )
        if not is_global_id(edge.target):
            target = graph.nodes.get(edge.target)
            if target is None:
                out.append(
                    Diagnostic("error", "edge-dangling-target", f"edge target {edge.target!r} not found", edge.id)
                )
            elif rule.target_types and target.type not in rule.target_types:
                out.append(
                    Diagnostic(
                        "error", "edge-bad-target-type", f"{edge.type!r} target may not be {target.type!r}", edge.id
                    )
                )
        return out

allows_edge_type(edge_type)

Report whether an edge type is part of this profile.

Parameters:

Name Type Description Default
edge_type str

The edge type to check.

required

Returns:

Type Description
bool

True if the type is known.

Source code in src/ideagraph/kg/profile.py
107
108
109
110
111
112
113
114
115
116
117
def allows_edge_type(self, edge_type: str) -> bool:
    """Report whether an edge type is part of this profile.

    Args:
        edge_type: The edge type to check.

    Returns:
        ``True`` if the type is known.

    """
    return edge_type in self.edge_types

allows_node_type(node_type)

Report whether a node type is part of this profile.

Parameters:

Name Type Description Default
node_type str

The node type to check.

required

Returns:

Type Description
bool

True if the type is known.

Source code in src/ideagraph/kg/profile.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
def allows_node_type(self, node_type: str) -> bool:
    """Report whether a node type is part of this profile.

    Args:
        node_type: The node type to check.

    Returns:
        ``True`` if the type is known.

    """
    return node_type in self.node_types

validate(graph)

Structurally validate a graph against this profile.

Checks that every node/edge type is known, that required properties are present, that edges reference existing source nodes, and that endpoint types satisfy the edge rule. Cross-article targets (global ids) are not resolved here — that is a library-level concern.

Parameters:

Name Type Description Default
graph KnowledgeGraph

The graph to validate.

required

Returns:

Type Description
list[Diagnostic]

A list of diagnostics (empty if the graph conforms).

Source code in src/ideagraph/kg/profile.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def validate(self, graph: KnowledgeGraph) -> list[Diagnostic]:
    """Structurally validate a graph against this profile.

    Checks that every node/edge type is known, that required properties are
    present, that edges reference existing source nodes, and that endpoint
    types satisfy the edge rule. Cross-article targets (global ids) are not
    resolved here — that is a library-level concern.

    Args:
        graph: The graph to validate.

    Returns:
        A list of diagnostics (empty if the graph conforms).

    """
    out: list[Diagnostic] = []
    for node in graph.nodes.values():
        rule = self.node_types.get(node.type)
        if rule is None:
            out.append(Diagnostic("error", "unknown-node-type", f"unknown node type {node.type!r}", node.id))
            continue
        for prop in sorted(rule.required_properties - set(node.properties)):
            out.append(Diagnostic("error", "missing-property", f"node missing property {prop!r}", node.id))
    for edge in graph.edges.values():
        out.extend(self._validate_edge(graph, edge))
    return out

StaleImport dataclass

A copied node whose origin has drifted since it was extracted.

Attributes:

Name Type Description
node_id str

The local id of the imported node.

source_gid str

The origin's global id (article_id#node_id).

reason str

"changed" (origin text differs from the stamp) or "missing" (the origin is no longer resolvable).

Source code in src/ideagraph/kg/extract.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
@dataclass(frozen=True)
class StaleImport:
    """A copied node whose origin has drifted since it was extracted.

    Attributes:
        node_id: The local id of the imported node.
        source_gid: The origin's global id (``article_id#node_id``).
        reason: ``"changed"`` (origin text differs from the stamp) or
            ``"missing"`` (the origin is no longer resolvable).
    """

    node_id: str
    source_gid: str
    reason: str

available_profiles()

Return the names of all registered profiles.

Returns:

Type Description
list[str]

Registered profile names, sorted.

Source code in src/ideagraph/kg/profile.py
218
219
220
221
222
223
224
225
def available_profiles() -> list[str]:
    """Return the names of all registered profiles.

    Returns:
        Registered profile names, sorted.

    """
    return sorted(_PROFILES)

dumps_prov(graph, *, indent=2)

Serialise a knowledge graph to a PROV-JSON string.

Parameters:

Name Type Description Default
graph KnowledgeGraph

The graph to export.

required
indent int

Indentation passed to :func:json.dumps.

2

Returns:

Type Description
str

The PROV-JSON document as a string.

Source code in src/ideagraph/kg/prov.py
141
142
143
144
145
146
147
148
149
150
151
152
def dumps_prov(graph: KnowledgeGraph, *, indent: int = 2) -> str:
    """Serialise a knowledge graph to a PROV-JSON string.

    Args:
        graph: The graph to export.
        indent: Indentation passed to :func:`json.dumps`.

    Returns:
        The PROV-JSON document as a string.

    """
    return json.dumps(to_prov(graph), indent=indent, ensure_ascii=False)

extract_subgraph(graph, seeds, *, hops=1, article_id=None, stamp_provenance=True)

Extract the induced subgraph around seeds into a new graph.

The result contains every node within hops edges of a seed, every edge whose endpoints are both included, and every cross-article edge leaving an included node. Copies are deep enough to be independent of the source: the new graph can be mutated or saved without touching the original.

Parameters:

Name Type Description Default
graph KnowledgeGraph

The source graph to carve from.

required
seeds set[str]

Seed node ids (ids absent from the source are ignored).

required
hops int

Number of edge hops to expand from the seeds.

1
article_id str | None

article_id for the new graph; defaults to None (the caller sets one before the subgraph can be referenced globally).

None
stamp_provenance bool

If true and the source has an article_id, record each copied node's origin under properties["source_gid"] (an existing stamp is preserved, so re-extraction keeps the first origin).

True

Returns:

Type Description
KnowledgeGraph

A new :class:KnowledgeGraph holding the induced subgraph.

Source code in src/ideagraph/kg/extract.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def extract_subgraph(
    graph: KnowledgeGraph,
    seeds: set[str],
    *,
    hops: int = 1,
    article_id: str | None = None,
    stamp_provenance: bool = True,
) -> KnowledgeGraph:
    """Extract the induced subgraph around ``seeds`` into a new graph.

    The result contains every node within ``hops`` edges of a seed, every edge
    whose endpoints are both included, and every cross-article edge leaving an
    included node. Copies are deep enough to be independent of the source: the
    new graph can be mutated or saved without touching the original.

    Args:
        graph: The source graph to carve from.
        seeds: Seed node ids (ids absent from the source are ignored).
        hops: Number of edge hops to expand from the seeds.
        article_id: ``article_id`` for the new graph; defaults to ``None`` (the
            caller sets one before the subgraph can be referenced globally).
        stamp_provenance: If true and the source has an ``article_id``, record
            each copied node's origin under ``properties["source_gid"]`` (an
            existing stamp is preserved, so re-extraction keeps the first origin).

    Returns:
        A new :class:`KnowledgeGraph` holding the induced subgraph.
    """
    keep = neighbourhood(graph, seeds, hops=hops)
    out = KnowledgeGraph(article_id=article_id)
    can_stamp = stamp_provenance and graph.article_id is not None
    for nid in keep:
        src = graph.nodes[nid]
        properties = dict(src.properties)
        if can_stamp and SOURCE_GID_KEY not in properties:
            properties[SOURCE_GID_KEY] = graph.global_id(nid)
            properties[SOURCE_HASH_KEY] = _text_digest(src.text)
        out.add_node(
            Node(
                type=src.type,
                text=src.text,
                id=src.id,
                tags=list(src.tags),
                properties=properties,
                created_at=src.created_at,
                updated_at=src.updated_at,
            )
        )
    for edge in graph.edges.values():
        if edge.source not in keep:
            continue
        if edge.target in keep or is_global_id(edge.target):
            out.add_edge(
                Edge(
                    type=edge.type,
                    source=edge.source,
                    target=edge.target,
                    id=edge.id,
                    properties=dict(edge.properties),
                    created_at=edge.created_at,
                )
            )
    return out

find_stale_imports(graph, resolve)

Report imported nodes whose origin has changed or disappeared.

Only nodes stamped by :func:extract_subgraph (carrying both a source_gid and a source_hash) are checked. Local edits to a copied node do not flag it — the stamp records the origin's text at extraction time, so only upstream drift is reported.

Parameters:

Name Type Description Default
graph KnowledgeGraph

The graph holding imported nodes (e.g. a project graph).

required
resolve GraphResolver

Maps an article_id to its current graph, or None if the origin can no longer be found.

required

Returns:

Name Type Description
One list[StaleImport]

class:StaleImport per drifted or missing origin, in node order.

Source code in src/ideagraph/kg/extract.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def find_stale_imports(graph: KnowledgeGraph, resolve: GraphResolver) -> list[StaleImport]:
    """Report imported nodes whose origin has changed or disappeared.

    Only nodes stamped by :func:`extract_subgraph` (carrying both a
    ``source_gid`` and a ``source_hash``) are checked. Local edits to a copied
    node do not flag it — the stamp records the *origin's* text at extraction
    time, so only upstream drift is reported.

    Args:
        graph: The graph holding imported nodes (e.g. a project graph).
        resolve: Maps an ``article_id`` to its current graph, or ``None`` if the
            origin can no longer be found.

    Returns:
        One :class:`StaleImport` per drifted or missing origin, in node order.
    """
    stale: list[StaleImport] = []
    cache: dict[str, KnowledgeGraph | None] = {}
    for node in graph.nodes.values():
        gid = node.properties.get(SOURCE_GID_KEY)
        stamp = node.properties.get(SOURCE_HASH_KEY)
        if not gid or stamp is None:
            continue
        try:
            article_id, origin_node_id = parse_global_id(gid)
        except ValueError:
            stale.append(StaleImport(node.id, gid, "missing"))
            continue
        if article_id not in cache:
            cache[article_id] = resolve(article_id)
        origin = cache[article_id]
        if origin is None or origin_node_id not in origin.nodes:
            stale.append(StaleImport(node.id, gid, "missing"))
        elif _text_digest(origin.nodes[origin_node_id].text) != stamp:
            stale.append(StaleImport(node.id, gid, "changed"))
    return stale

from_prov(document)

Reconstruct a knowledge graph from a PROV-JSON document.

Parameters:

Name Type Description Default
document dict[str, Any]

A PROV-JSON document as produced by :func:to_prov.

required

Returns:

Type Description
KnowledgeGraph

The reconstructed graph. External endpoint stubs (global ids or the

KnowledgeGraph

generic ck:external / ck:node placeholders) are not materialised

KnowledgeGraph

as nodes.

Source code in src/ideagraph/kg/prov.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def from_prov(document: dict[str, Any]) -> KnowledgeGraph:
    """Reconstruct a knowledge graph from a PROV-JSON document.

    Args:
        document: A PROV-JSON document as produced by :func:`to_prov`.

    Returns:
        The reconstructed graph. External endpoint stubs (global ids or the
        generic ``ck:external`` / ``ck:node`` placeholders) are not materialised
        as nodes.

    """
    graph = KnowledgeGraph()
    for bucket in ("entity", "activity", "agent"):
        for qn, attrs in document.get(bucket, {}).items():
            local = _local(qn)
            if is_global_id(local) or attrs.get("prov:type") in ("ck:external", "ck:node"):
                continue
            graph.add_node(_node_from_attrs(qn, attrs))

    for name, payload_map in document.items():
        if name in _RELATION_ROLES_REV:
            etype, s_role, o_role = _RELATION_ROLES_REV[name]
            for qn, payload in payload_map.items():
                graph.add_edge(
                    Edge(type=etype, source=_local(payload[s_role]), target=_local(payload[o_role]), id=_local(qn))
                )
        elif name == "wasInfluencedBy":
            for qn, payload in payload_map.items():
                graph.add_edge(
                    Edge(
                        type=payload["ck:predicate"],
                        source=_local(payload["prov:influencee"]),
                        target=_local(payload["prov:influencer"]),
                        id=_local(qn),
                    )
                )
    return graph

get_profile(name)

Return a registered profile by name.

Parameters:

Name Type Description Default
name str

The profile name.

required

Returns:

Type Description
Profile

The profile.

Raises:

Type Description
KeyError

If no profile with that name is registered.

Source code in src/ideagraph/kg/profile.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
def get_profile(name: str) -> Profile:
    """Return a registered profile by name.

    Args:
        name: The profile name.

    Returns:
        The profile.

    Raises:
        KeyError: If no profile with that name is registered.

    """
    return _PROFILES[name]

loads_prov(text)

Deserialise a knowledge graph from a PROV-JSON string.

Parameters:

Name Type Description Default
text str

A PROV-JSON document as produced by :func:dumps_prov.

required

Returns:

Type Description
KnowledgeGraph

The reconstructed graph.

Source code in src/ideagraph/kg/prov.py
220
221
222
223
224
225
226
227
228
229
230
def loads_prov(text: str) -> KnowledgeGraph:
    """Deserialise a knowledge graph from a PROV-JSON string.

    Args:
        text: A PROV-JSON document as produced by :func:`dumps_prov`.

    Returns:
        The reconstructed graph.

    """
    return from_prov(json.loads(text))

register_profile(profile)

Register a profile so it can be looked up by name.

Parameters:

Name Type Description Default
profile Profile

The profile to register.

required

Returns:

Type Description
Profile

The registered profile.

Source code in src/ideagraph/kg/profile.py
188
189
190
191
192
193
194
195
196
197
198
199
def register_profile(profile: Profile) -> Profile:
    """Register a profile so it can be looked up by name.

    Args:
        profile: The profile to register.

    Returns:
        The registered profile.

    """
    _PROFILES[profile.name] = profile
    return profile

to_prov(graph)

Convert a knowledge graph to a PROV-JSON document.

Parameters:

Name Type Description Default
graph KnowledgeGraph

The graph to export.

required

Returns:

Type Description
dict[str, Any]

A PROV-JSON document as a plain dictionary.

Source code in src/ideagraph/kg/prov.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def to_prov(graph: KnowledgeGraph) -> dict[str, Any]:
    """Convert a knowledge graph to a PROV-JSON document.

    Args:
        graph: The graph to export.

    Returns:
        A PROV-JSON document as a plain dictionary.

    """
    entity: dict[str, Any] = {}
    activity: dict[str, Any] = {}
    agent: dict[str, Any] = {}
    buckets = {_ACTIVITY: activity, _AGENT: agent}

    for node in graph.nodes.values():
        buckets.get(node.type, entity)[_qn(node.id)] = _node_attrs(node)

    def _ensure_entity(node_id: str) -> None:
        qn = _qn(node_id)
        if qn not in entity and qn not in activity and qn not in agent:
            entity[qn] = {"prov:type": "ck:external"} if is_global_id(node_id) else {"prov:type": "ck:node"}

    relations: dict[str, dict[str, Any]] = {
        name: {} for name in ("used", "wasGeneratedBy", "wasDerivedFrom", "wasAttributedTo", "wasInfluencedBy")
    }
    for edge in graph.edges.values():
        _ensure_entity(edge.source)
        _ensure_entity(edge.target)
        subject, obj = _qn(edge.source), _qn(edge.target)
        roles = _RELATION_ROLES.get(edge.type)
        if roles is not None:
            name, s_role, o_role = roles
            relations[name][_qn(edge.id)] = {s_role: subject, o_role: obj}
        else:
            relations["wasInfluencedBy"][_qn(edge.id)] = {
                "prov:influencee": subject,
                "prov:influencer": obj,
                "ck:predicate": edge.type,
            }

    document: dict[str, Any] = {"prefix": {"ck": CK_NAMESPACE}}
    for name, collection in (("entity", entity), ("activity", activity), ("agent", agent), *relations.items()):
        if collection:
            document[name] = collection
    return document