scientific-computing-system 1.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cds/__init__.py +102 -0
- cds/__main__.py +8 -0
- cds/_version.py +10 -0
- cds/cli/__init__.py +104 -0
- cds/cli/__main__.py +8 -0
- cds/cli/_handlers.py +405 -0
- cds/cli/_parser.py +156 -0
- cds/cli/_style.py +117 -0
- cds/core/__init__.py +5 -0
- cds/core/_numeric.py +92 -0
- cds/core/models.py +166 -0
- cds/data_analysis/__init__.py +30 -0
- cds/data_analysis/dataset.py +122 -0
- cds/data_analysis/loader.py +75 -0
- cds/data_analysis/pandas_io.py +108 -0
- cds/data_analysis/transform.py +36 -0
- cds/data_analysis/viz.py +88 -0
- cds/diffeq/__init__.py +29 -0
- cds/diffeq/_implicit.py +349 -0
- cds/diffeq/solvers.py +312 -0
- cds/graph/__init__.py +31 -0
- cds/graph/algorithms.py +399 -0
- cds/hypothesis/__init__.py +48 -0
- cds/hypothesis/evaluator.py +369 -0
- cds/hypothesis/generator.py +275 -0
- cds/knowledge/__init__.py +41 -0
- cds/knowledge/graph.py +489 -0
- cds/knowledge/notes.py +231 -0
- cds/knowledge/retrieval.py +167 -0
- cds/math_utils/__init__.py +41 -0
- cds/math_utils/calculus.py +40 -0
- cds/math_utils/linalg.py +461 -0
- cds/math_utils/special.py +167 -0
- cds/ml/__init__.py +25 -0
- cds/ml/clustering.py +190 -0
- cds/ml/decomposition.py +172 -0
- cds/ml/linear_models.py +225 -0
- cds/ml/neighbors.py +162 -0
- cds/ml/neural.py +204 -0
- cds/ml/preprocessing.py +131 -0
- cds/ml/tree.py +202 -0
- cds/modeling/__init__.py +40 -0
- cds/modeling/_base.py +210 -0
- cds/modeling/_nodes.py +526 -0
- cds/modeling/expression.py +81 -0
- cds/modeling/model.py +134 -0
- cds/modeling/solver.py +176 -0
- cds/montecarlo/__init__.py +21 -0
- cds/montecarlo/methods.py +272 -0
- cds/nlp/__init__.py +170 -0
- cds/nlp/attention.py +285 -0
- cds/nlp/autograd/__init__.py +70 -0
- cds/nlp/autograd/_grad.py +120 -0
- cds/nlp/autograd/ops.py +133 -0
- cds/nlp/autograd/tensor.py +298 -0
- cds/nlp/bpe.py +449 -0
- cds/nlp/data.py +70 -0
- cds/nlp/embed.py +190 -0
- cds/nlp/layers.py +259 -0
- cds/nlp/model.py +456 -0
- cds/nlp/optim.py +163 -0
- cds/nlp/training.py +167 -0
- cds/nlp/viz.py +254 -0
- cds/numerical_integration/__init__.py +31 -0
- cds/numerical_integration/quadrature.py +480 -0
- cds/optimization/__init__.py +18 -0
- cds/optimization/_metaheuristics.py +287 -0
- cds/optimization/minimize.py +425 -0
- cds/plot/__init__.py +49 -0
- cds/plot/_backend.py +32 -0
- cds/plot/charts.py +462 -0
- cds/probability/__init__.py +55 -0
- cds/probability/_advanced.py +283 -0
- cds/probability/distributions.py +236 -0
- cds/py.typed +0 -0
- cds/quantum/__init__.py +51 -0
- cds/quantum/circuit.py +92 -0
- cds/quantum/multi_qubit.py +255 -0
- cds/quantum/simulator.py +38 -0
- cds/scientific/__init__.py +34 -0
- cds/scientific/constants.py +35 -0
- cds/scientific/formulas.py +116 -0
- cds/signals/__init__.py +43 -0
- cds/signals/filters.py +421 -0
- cds/signals/processing.py +224 -0
- cds/stats/__init__.py +83 -0
- cds/stats/_distributions.py +102 -0
- cds/stats/descriptive.py +172 -0
- cds/stats/hypothesis_tests.py +403 -0
- cds/stats/nonparametric.py +131 -0
- cds/stats/regression.py +51 -0
- cds/stats/time_series.py +408 -0
- scientific_computing_system-1.6.2.dist-info/METADATA +741 -0
- scientific_computing_system-1.6.2.dist-info/RECORD +97 -0
- scientific_computing_system-1.6.2.dist-info/WHEEL +4 -0
- scientific_computing_system-1.6.2.dist-info/entry_points.txt +2 -0
- scientific_computing_system-1.6.2.dist-info/licenses/LICENSE +21 -0
cds/knowledge/graph.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Knowledge graph — typed relationships between named concepts.
|
|
2
|
+
|
|
3
|
+
The core of :mod:`cds.knowledge`. A :class:`KnowledgeGraph` stores named
|
|
4
|
+
:class:`Concept` nodes (each with optional description, tags, and free-form
|
|
5
|
+
metadata) connected by typed, directed :class:`Relation` edges
|
|
6
|
+
(``"is-a"``, ``"depends-on"``, ``"related-to"``, …).
|
|
7
|
+
|
|
8
|
+
This is deliberately a *purpose-built* structure rather than a wrapper
|
|
9
|
+
around :mod:`cds.graph`. The latter is hard-wired to dense integer vertex
|
|
10
|
+
IDs and carries only an untyped float weight per edge, whereas a knowledge
|
|
11
|
+
graph's value is exactly string-keyed nodes with rich metadata and typed
|
|
12
|
+
relationships. The traversal here (BFS path-finding, transitive closure,
|
|
13
|
+
cycle detection) is small and self-contained.
|
|
14
|
+
|
|
15
|
+
Design choices:
|
|
16
|
+
|
|
17
|
+
* Relations are **directed** (``source -> target``): ``"A depends-on B"``
|
|
18
|
+
is not symmetric. For convenience, :meth:`KnowledgeGraph.neighbors`
|
|
19
|
+
reports both directions, while :meth:`neighbors_in` / :meth:`neighbors_out`
|
|
20
|
+
expose the direction when you need it.
|
|
21
|
+
* Persistence mirrors the :class:`cds.nlp.bpe.BPEMerge` idiom: every
|
|
22
|
+
entity serializes via :meth:`to_dict`/``from_dict``, and the owning
|
|
23
|
+
:class:`KnowledgeGraph` orchestrates :meth:`save`/:meth:`load` with the
|
|
24
|
+
stdlib :mod:`json` module.
|
|
25
|
+
|
|
26
|
+
References:
|
|
27
|
+
- Sowa, J.F. (1976). "Conceptual Graphs for a Data Base Interface"
|
|
28
|
+
— the typed-relationship convention used by :class:`Relation`.
|
|
29
|
+
- Cormen et al., CLRS §22 — the BFS shortest-path / reachability
|
|
30
|
+
algorithms used by :meth:`find_path` and :meth:`reachable`.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
from collections import deque
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Concept:
|
|
43
|
+
"""A named node in the knowledge graph.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
name: the unique, human-readable concept identifier (also its
|
|
47
|
+
dictionary key inside a :class:`KnowledgeGraph`).
|
|
48
|
+
description: optional one- or two-line summary of the concept.
|
|
49
|
+
tags: free-form labels for grouping and retrieval (e.g.
|
|
50
|
+
``["physics", "mechanics"]``).
|
|
51
|
+
metadata: additional string-valued properties (e.g. source URLs,
|
|
52
|
+
units) that don't fit the structured fields above.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
description: str | None = None
|
|
57
|
+
tags: list[str] = field(default_factory=list)
|
|
58
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
def __eq__(self, other: object) -> bool:
|
|
61
|
+
"""Two concepts are equal iff their ``name`` fields match."""
|
|
62
|
+
return isinstance(other, Concept) and other.name == self.name
|
|
63
|
+
|
|
64
|
+
def __hash__(self) -> int:
|
|
65
|
+
"""Hash keyed on the ``Concept`` tag and name (matches ``__eq__``)."""
|
|
66
|
+
return hash(("Concept", self.name))
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, object]:
|
|
69
|
+
"""Serialize this concept to a JSON-friendly dict."""
|
|
70
|
+
return {
|
|
71
|
+
"name": self.name,
|
|
72
|
+
"description": self.description,
|
|
73
|
+
"tags": list(self.tags),
|
|
74
|
+
"metadata": dict(self.metadata),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict[str, object]) -> Concept:
|
|
79
|
+
"""Reconstruct a :class:`Concept` from :meth:`to_dict` output.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ValueError: if ``data`` is missing keys or has the wrong types.
|
|
83
|
+
"""
|
|
84
|
+
name = data["name"]
|
|
85
|
+
if not isinstance(name, str):
|
|
86
|
+
raise ValueError(f"Invalid concept name: {name!r}")
|
|
87
|
+
description = data["description"]
|
|
88
|
+
if description is not None and not isinstance(description, str):
|
|
89
|
+
raise ValueError(f"Invalid concept description: {description!r}")
|
|
90
|
+
tags_raw = data["tags"]
|
|
91
|
+
if not isinstance(tags_raw, list) or not all(isinstance(t, str) for t in tags_raw):
|
|
92
|
+
raise ValueError(f"Invalid concept tags: {tags_raw!r}")
|
|
93
|
+
metadata_raw = data["metadata"]
|
|
94
|
+
if not isinstance(metadata_raw, dict) or not all(
|
|
95
|
+
isinstance(k, str) and isinstance(v, str) for k, v in metadata_raw.items()
|
|
96
|
+
):
|
|
97
|
+
raise ValueError(f"Invalid concept metadata: {metadata_raw!r}")
|
|
98
|
+
return cls(
|
|
99
|
+
name=name,
|
|
100
|
+
description=description,
|
|
101
|
+
tags=list(tags_raw),
|
|
102
|
+
metadata=dict(metadata_raw),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class Relation:
|
|
108
|
+
"""A typed, directed edge ``source -> target`` between two concepts.
|
|
109
|
+
|
|
110
|
+
Attributes:
|
|
111
|
+
source: name of the origin concept.
|
|
112
|
+
target: name of the destination concept.
|
|
113
|
+
kind: the relationship type (e.g. ``"is-a"``, ``"depends-on"``,
|
|
114
|
+
``"related-to"``). Semantics are caller-defined; the graph does
|
|
115
|
+
not interpret kinds beyond using them for filtering.
|
|
116
|
+
weight: optional numeric strength (default 1.0). Higher is stronger;
|
|
117
|
+
used by callers for ranking, not by the core traversal.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
source: str
|
|
121
|
+
target: str
|
|
122
|
+
kind: str
|
|
123
|
+
weight: float = 1.0
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, object]:
|
|
126
|
+
"""Serialize this relation to a JSON-friendly dict."""
|
|
127
|
+
return {
|
|
128
|
+
"source": self.source,
|
|
129
|
+
"target": self.target,
|
|
130
|
+
"kind": self.kind,
|
|
131
|
+
"weight": self.weight,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_dict(cls, data: dict[str, object]) -> Relation:
|
|
136
|
+
"""Reconstruct a :class:`Relation` from :meth:`to_dict` output.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ValueError: if ``data`` is missing keys or has the wrong types.
|
|
140
|
+
"""
|
|
141
|
+
source = data["source"]
|
|
142
|
+
target = data["target"]
|
|
143
|
+
kind = data["kind"]
|
|
144
|
+
if not isinstance(source, str):
|
|
145
|
+
raise ValueError(f"Invalid relation source: {source!r}")
|
|
146
|
+
if not isinstance(target, str):
|
|
147
|
+
raise ValueError(f"Invalid relation target: {target!r}")
|
|
148
|
+
if not isinstance(kind, str):
|
|
149
|
+
raise ValueError(f"Invalid relation kind: {kind!r}")
|
|
150
|
+
weight = data["weight"]
|
|
151
|
+
if not isinstance(weight, int | float) or isinstance(weight, bool):
|
|
152
|
+
raise ValueError(f"Invalid relation weight: {weight!r}")
|
|
153
|
+
return cls(source=source, target=target, kind=kind, weight=float(weight))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class KnowledgeGraph:
|
|
158
|
+
"""A knowledge graph of named concepts and typed relations.
|
|
159
|
+
|
|
160
|
+
Attributes:
|
|
161
|
+
name: human-readable graph title (used in :meth:`to_markdown`).
|
|
162
|
+
concepts: mapping of concept name to :class:`Concept` node.
|
|
163
|
+
relations: ordered list of :class:`Relation` edges.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
name: str
|
|
167
|
+
concepts: dict[str, Concept] = field(default_factory=dict)
|
|
168
|
+
relations: list[Relation] = field(default_factory=list)
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------ #
|
|
171
|
+
# Construction
|
|
172
|
+
# ------------------------------------------------------------------ #
|
|
173
|
+
def add_concept(
|
|
174
|
+
self,
|
|
175
|
+
name: str,
|
|
176
|
+
description: str | None = None,
|
|
177
|
+
tags: list[str] | None = None,
|
|
178
|
+
metadata: dict[str, str] | None = None,
|
|
179
|
+
) -> Concept:
|
|
180
|
+
"""Add a concept, returning the stored node.
|
|
181
|
+
|
|
182
|
+
If ``name`` already exists, the existing concept is returned
|
|
183
|
+
unchanged (idempotent) rather than overwritten.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
name: unique concept identifier.
|
|
187
|
+
description: optional summary.
|
|
188
|
+
tags: optional grouping labels.
|
|
189
|
+
metadata: optional string-valued properties.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
the stored :class:`Concept` (newly created or the pre-existing one).
|
|
193
|
+
"""
|
|
194
|
+
if name in self.concepts:
|
|
195
|
+
return self.concepts[name]
|
|
196
|
+
concept = Concept(
|
|
197
|
+
name=name,
|
|
198
|
+
description=description,
|
|
199
|
+
tags=list(tags) if tags else [],
|
|
200
|
+
metadata=dict(metadata) if metadata else {},
|
|
201
|
+
)
|
|
202
|
+
self.concepts[name] = concept
|
|
203
|
+
return concept
|
|
204
|
+
|
|
205
|
+
def add_relation(
|
|
206
|
+
self,
|
|
207
|
+
source: str,
|
|
208
|
+
target: str,
|
|
209
|
+
kind: str,
|
|
210
|
+
weight: float = 1.0,
|
|
211
|
+
) -> Relation:
|
|
212
|
+
"""Add a typed, directed relation ``source -> target``.
|
|
213
|
+
|
|
214
|
+
Both endpoints must already exist as concepts (use
|
|
215
|
+
:meth:`link_concepts` to auto-create them).
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
KeyError: if ``source`` or ``target`` is not a known concept.
|
|
219
|
+
"""
|
|
220
|
+
for endpoint, label in ((source, "source"), (target, "target")):
|
|
221
|
+
if endpoint not in self.concepts:
|
|
222
|
+
raise KeyError(f"unknown {label} concept: {endpoint!r}")
|
|
223
|
+
relation = Relation(source=source, target=target, kind=kind, weight=weight)
|
|
224
|
+
self.relations.append(relation)
|
|
225
|
+
return relation
|
|
226
|
+
|
|
227
|
+
def link_concepts(
|
|
228
|
+
self,
|
|
229
|
+
source: str,
|
|
230
|
+
target: str,
|
|
231
|
+
kind: str,
|
|
232
|
+
weight: float = 1.0,
|
|
233
|
+
) -> Relation:
|
|
234
|
+
"""Auto-create both concepts (if missing) and add a relation between them."""
|
|
235
|
+
self.add_concept(source)
|
|
236
|
+
self.add_concept(target)
|
|
237
|
+
return self.add_relation(source, target, kind, weight)
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------ #
|
|
240
|
+
# Queries
|
|
241
|
+
# ------------------------------------------------------------------ #
|
|
242
|
+
def neighbors(self, name: str, kind: str | None = None) -> list[str]:
|
|
243
|
+
"""Undirected neighbors of ``name`` — every concept directly linked.
|
|
244
|
+
|
|
245
|
+
A relation touching ``name`` at either endpoint contributes its
|
|
246
|
+
*other* endpoint. ``kind`` optionally restricts to one relation type.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
KeyError: if ``name`` is not a known concept.
|
|
250
|
+
"""
|
|
251
|
+
self._require_concept(name)
|
|
252
|
+
found: list[str] = []
|
|
253
|
+
for relation in self.relations:
|
|
254
|
+
if kind is not None and relation.kind != kind:
|
|
255
|
+
continue
|
|
256
|
+
if relation.source == name and relation.target not in found:
|
|
257
|
+
found.append(relation.target)
|
|
258
|
+
elif relation.target == name and relation.source not in found:
|
|
259
|
+
found.append(relation.source)
|
|
260
|
+
return found
|
|
261
|
+
|
|
262
|
+
def neighbors_out(self, name: str, kind: str | None = None) -> list[str]:
|
|
263
|
+
"""Concepts that ``name`` points to via outgoing relations.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
KeyError: if ``name`` is not a known concept.
|
|
267
|
+
"""
|
|
268
|
+
self._require_concept(name)
|
|
269
|
+
found: list[str] = []
|
|
270
|
+
for relation in self.relations:
|
|
271
|
+
if kind is not None and relation.kind != kind:
|
|
272
|
+
continue
|
|
273
|
+
if relation.source == name and relation.target not in found:
|
|
274
|
+
found.append(relation.target)
|
|
275
|
+
return found
|
|
276
|
+
|
|
277
|
+
def neighbors_in(self, name: str, kind: str | None = None) -> list[str]:
|
|
278
|
+
"""Concepts that point at ``name`` via incoming relations.
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
KeyError: if ``name`` is not a known concept.
|
|
282
|
+
"""
|
|
283
|
+
self._require_concept(name)
|
|
284
|
+
found: list[str] = []
|
|
285
|
+
for relation in self.relations:
|
|
286
|
+
if kind is not None and relation.kind != kind:
|
|
287
|
+
continue
|
|
288
|
+
if relation.target == name and relation.source not in found:
|
|
289
|
+
found.append(relation.source)
|
|
290
|
+
return found
|
|
291
|
+
|
|
292
|
+
def find_path(self, source: str, target: str) -> list[str] | None:
|
|
293
|
+
"""Shortest undirected path (by hop count) from ``source`` to ``target``.
|
|
294
|
+
|
|
295
|
+
Returns the sequence of concept names ``[source, ..., target]``, or
|
|
296
|
+
``None`` if no path exists or the endpoints are unknown. A path of
|
|
297
|
+
length 1 (``source == target``) returns ``[source]``.
|
|
298
|
+
|
|
299
|
+
Uses BFS following the edges in either direction, so the returned
|
|
300
|
+
path may traverse relations against their direction.
|
|
301
|
+
"""
|
|
302
|
+
if source not in self.concepts or target not in self.concepts:
|
|
303
|
+
return None
|
|
304
|
+
if source == target:
|
|
305
|
+
return [source]
|
|
306
|
+
predecessor: dict[str, str] = {source: source}
|
|
307
|
+
queue: deque[str] = deque([source])
|
|
308
|
+
while queue:
|
|
309
|
+
node = queue.popleft()
|
|
310
|
+
for neighbor in self.neighbors(node):
|
|
311
|
+
if neighbor in predecessor:
|
|
312
|
+
continue
|
|
313
|
+
predecessor[neighbor] = node
|
|
314
|
+
if neighbor == target:
|
|
315
|
+
return _reconstruct_path(predecessor, target)
|
|
316
|
+
queue.append(neighbor)
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
def reachable(self, start: str) -> set[str]:
|
|
320
|
+
"""All concepts reachable from ``start`` over undirected edges (incl. itself).
|
|
321
|
+
|
|
322
|
+
Returns an empty set if ``start`` is not a known concept.
|
|
323
|
+
"""
|
|
324
|
+
if start not in self.concepts:
|
|
325
|
+
return set()
|
|
326
|
+
seen: set[str] = set()
|
|
327
|
+
queue: deque[str] = deque([start])
|
|
328
|
+
seen.add(start)
|
|
329
|
+
while queue:
|
|
330
|
+
node = queue.popleft()
|
|
331
|
+
for neighbor in self.neighbors(node):
|
|
332
|
+
if neighbor not in seen:
|
|
333
|
+
seen.add(neighbor)
|
|
334
|
+
queue.append(neighbor)
|
|
335
|
+
return seen
|
|
336
|
+
|
|
337
|
+
def find_cycles(self) -> list[list[str]]:
|
|
338
|
+
"""Return every directed cycle in the graph as a list of concept names.
|
|
339
|
+
|
|
340
|
+
Each cycle is reported once, normalized to start at its
|
|
341
|
+
lexicographically smallest member so the same cycle is not reported
|
|
342
|
+
from every starting rotation. Self-loops (a relation whose source and
|
|
343
|
+
target are equal) are returned as ``[name]``.
|
|
344
|
+
|
|
345
|
+
Uses DFS back-edge detection with an explicit recursion-emulating
|
|
346
|
+
stack so deep graphs do not hit Python's recursion limit.
|
|
347
|
+
"""
|
|
348
|
+
adj: dict[str, list[str]] = {name: [] for name in self.concepts}
|
|
349
|
+
for relation in self.relations:
|
|
350
|
+
adj[relation.source].append(relation.target)
|
|
351
|
+
|
|
352
|
+
found: set[tuple[str, ...]] = set()
|
|
353
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
354
|
+
color: dict[str, int] = {name: WHITE for name in self.concepts}
|
|
355
|
+
|
|
356
|
+
for root in sorted(self.concepts):
|
|
357
|
+
if color[root] != WHITE:
|
|
358
|
+
continue
|
|
359
|
+
# Each stack frame: the node plus an iterator position over its successors.
|
|
360
|
+
stack: list[tuple[str, list[str]]] = [(root, list(adj[root]))]
|
|
361
|
+
color[root] = GRAY
|
|
362
|
+
path: list[str] = [root]
|
|
363
|
+
while stack:
|
|
364
|
+
node, succs = stack[-1]
|
|
365
|
+
advanced = False
|
|
366
|
+
while succs:
|
|
367
|
+
nxt = succs.pop()
|
|
368
|
+
if color[nxt] == GRAY:
|
|
369
|
+
# Back edge: a cycle from nxt back along the current
|
|
370
|
+
# DFS path. A node is GRAY iff it is on ``path`` (we
|
|
371
|
+
# always append to ``path`` in lockstep with marking
|
|
372
|
+
# GRAY below), so ``nxt`` is guaranteed to be present.
|
|
373
|
+
cycle = path[path.index(nxt) :]
|
|
374
|
+
found.add(_normalize_cycle(cycle))
|
|
375
|
+
# Do not descend into the gray node; keep scanning successors.
|
|
376
|
+
continue
|
|
377
|
+
# The only remaining color is WHITE: descend into it.
|
|
378
|
+
color[nxt] = GRAY
|
|
379
|
+
path.append(nxt)
|
|
380
|
+
stack.append((nxt, list(adj[nxt])))
|
|
381
|
+
advanced = True
|
|
382
|
+
break
|
|
383
|
+
if not advanced:
|
|
384
|
+
# Exhausted this node's successors: mark black and pop.
|
|
385
|
+
color[node] = BLACK
|
|
386
|
+
path.pop()
|
|
387
|
+
stack.pop()
|
|
388
|
+
return [list(cycle) for cycle in sorted(found)]
|
|
389
|
+
|
|
390
|
+
def _require_concept(self, name: str) -> None:
|
|
391
|
+
if name not in self.concepts:
|
|
392
|
+
raise KeyError(f"unknown concept: {name!r}")
|
|
393
|
+
|
|
394
|
+
# ------------------------------------------------------------------ #
|
|
395
|
+
# Rendering & serialization
|
|
396
|
+
# ------------------------------------------------------------------ #
|
|
397
|
+
def to_markdown(self) -> str:
|
|
398
|
+
"""Render this graph as a structured Markdown document."""
|
|
399
|
+
lines: list[str] = [f"# Knowledge Graph: {self.name}", ""]
|
|
400
|
+
if not self.concepts:
|
|
401
|
+
lines += ["_No concepts._", ""]
|
|
402
|
+
else:
|
|
403
|
+
lines += ["## Concepts", ""]
|
|
404
|
+
for name in sorted(self.concepts):
|
|
405
|
+
lines.append(f"- **{name}**")
|
|
406
|
+
lines.append("")
|
|
407
|
+
if not self.relations:
|
|
408
|
+
lines += ["_No relations._", ""]
|
|
409
|
+
else:
|
|
410
|
+
lines += ["## Relations", ""]
|
|
411
|
+
for relation in self.relations:
|
|
412
|
+
lines.append(f"- `{relation.source}` --{relation.kind}--> `{relation.target}`")
|
|
413
|
+
lines.append("")
|
|
414
|
+
return "\n".join(lines)
|
|
415
|
+
|
|
416
|
+
def to_dict(self) -> dict[str, object]:
|
|
417
|
+
"""Serialize the whole graph to a JSON-friendly dict."""
|
|
418
|
+
return {
|
|
419
|
+
"name": self.name,
|
|
420
|
+
"concepts": [concept.to_dict() for concept in self.concepts.values()],
|
|
421
|
+
"relations": [relation.to_dict() for relation in self.relations],
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
@classmethod
|
|
425
|
+
def from_dict(cls, data: dict[str, object]) -> KnowledgeGraph:
|
|
426
|
+
"""Reconstruct a :class:`KnowledgeGraph` from :meth:`to_dict` output.
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
ValueError: if ``data`` is missing keys or has the wrong types.
|
|
430
|
+
"""
|
|
431
|
+
name = data["name"]
|
|
432
|
+
if not isinstance(name, str):
|
|
433
|
+
raise ValueError(f"Invalid graph name: {name!r}")
|
|
434
|
+
concepts_raw = data["concepts"]
|
|
435
|
+
relations_raw = data["relations"]
|
|
436
|
+
if not isinstance(concepts_raw, list):
|
|
437
|
+
raise ValueError(f"Invalid concepts list: {concepts_raw!r}")
|
|
438
|
+
if not isinstance(relations_raw, list):
|
|
439
|
+
raise ValueError(f"Invalid relations list: {relations_raw!r}")
|
|
440
|
+
graph = cls(name=name)
|
|
441
|
+
for item in concepts_raw:
|
|
442
|
+
if not isinstance(item, dict):
|
|
443
|
+
raise ValueError(f"Invalid concept entry: {item!r}")
|
|
444
|
+
concept = Concept.from_dict(item)
|
|
445
|
+
graph.concepts[concept.name] = concept
|
|
446
|
+
for item in relations_raw:
|
|
447
|
+
if not isinstance(item, dict):
|
|
448
|
+
raise ValueError(f"Invalid relation entry: {item!r}")
|
|
449
|
+
graph.relations.append(Relation.from_dict(item))
|
|
450
|
+
return graph
|
|
451
|
+
|
|
452
|
+
def save(self, path: str | Path) -> None:
|
|
453
|
+
"""Write this graph to ``path`` as indented UTF-8 JSON."""
|
|
454
|
+
Path(path).write_text(
|
|
455
|
+
json.dumps(self.to_dict(), ensure_ascii=False, indent=2),
|
|
456
|
+
encoding="utf-8",
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
@classmethod
|
|
460
|
+
def load(cls, path: str | Path) -> KnowledgeGraph:
|
|
461
|
+
"""Read a graph previously written by :meth:`save`.
|
|
462
|
+
|
|
463
|
+
Raises:
|
|
464
|
+
ValueError: if the file does not contain valid graph JSON.
|
|
465
|
+
"""
|
|
466
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
467
|
+
if not isinstance(data, dict):
|
|
468
|
+
raise ValueError(f"Invalid graph file (expected object): {data!r}")
|
|
469
|
+
return cls.from_dict(data)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _reconstruct_path(predecessor: dict[str, str], target: str) -> list[str]:
|
|
473
|
+
"""Walk the ``predecessor`` map backwards from ``target`` to the root."""
|
|
474
|
+
path: list[str] = [target]
|
|
475
|
+
node = target
|
|
476
|
+
while predecessor[node] != node:
|
|
477
|
+
node = predecessor[node]
|
|
478
|
+
path.append(node)
|
|
479
|
+
path.reverse()
|
|
480
|
+
return path
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _normalize_cycle(cycle: list[str]) -> tuple[str, ...]:
|
|
484
|
+
"""Return ``cycle`` rotated to start at its smallest member (as a tuple)."""
|
|
485
|
+
if not cycle:
|
|
486
|
+
return ()
|
|
487
|
+
pivot = cycle.index(min(cycle))
|
|
488
|
+
rotated = cycle[pivot:] + cycle[:pivot]
|
|
489
|
+
return tuple(rotated)
|