stemma-graph 0.3.0__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.
- stemma_graph/__init__.py +6 -0
- stemma_graph/contracts.py +108 -0
- stemma_graph/genealogy.py +242 -0
- stemma_graph-0.3.0.dist-info/METADATA +56 -0
- stemma_graph-0.3.0.dist-info/RECORD +8 -0
- stemma_graph-0.3.0.dist-info/WHEEL +5 -0
- stemma_graph-0.3.0.dist-info/licenses/LICENSE +21 -0
- stemma_graph-0.3.0.dist-info/top_level.txt +1 -0
stemma_graph/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Reusable genealogy core. This package alone is sufficient; Python standard library only."""
|
|
2
|
+
|
|
3
|
+
from .contracts import GraphError, GraphView, Node, RevisionRef, Succession
|
|
4
|
+
from .genealogy import Genealogy
|
|
5
|
+
|
|
6
|
+
__all__ = ["GraphError", "RevisionRef", "Node", "Succession", "GraphView", "Genealogy"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Shared value objects used by the portable graph package.
|
|
2
|
+
|
|
3
|
+
Read this module first: RevisionRef identifies text, Node participates in questions,
|
|
4
|
+
and Succession records the author-approved direction between documents.
|
|
5
|
+
These objects carry no file locations, rendering instructions, or publication policy."""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GraphError(ValueError):
|
|
11
|
+
"""A caller-supplied graph violates a semantic invariant."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def require(condition, message):
|
|
17
|
+
if not condition:
|
|
18
|
+
raise GraphError(message)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def identifier(value):
|
|
22
|
+
require(isinstance(value, str) and bool(value.strip()), "Expected nonempty string identifier")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, order=True)
|
|
26
|
+
class RevisionRef:
|
|
27
|
+
"""Identify one immutable revision of a document.
|
|
28
|
+
|
|
29
|
+
Document identity survives editing; revision identity identifies the exact text.
|
|
30
|
+
Ordering is lexical and exists for deterministic output, not historical chronology."""
|
|
31
|
+
|
|
32
|
+
document: str
|
|
33
|
+
revision: str
|
|
34
|
+
|
|
35
|
+
def __post_init__(self):
|
|
36
|
+
identifier(self.document)
|
|
37
|
+
identifier(self.revision)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Node:
|
|
42
|
+
"""Describe only the document information needed to validate a lineage.
|
|
43
|
+
|
|
44
|
+
A document may participate in several questions and retain several revisions.
|
|
45
|
+
A draft node can be stored, but does not enter a confirmed question view.
|
|
46
|
+
Archived ends future use globally; it does not remove any historical graph fact."""
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
revisions: tuple
|
|
50
|
+
questions: tuple = ()
|
|
51
|
+
confirmed: bool = True
|
|
52
|
+
archived: bool = False
|
|
53
|
+
|
|
54
|
+
def __post_init__(self):
|
|
55
|
+
"""Copy sequence inputs and validate local fields before a graph checks references."""
|
|
56
|
+
identifier(self.id)
|
|
57
|
+
# frozen=True prevents later assignment; copying also isolates caller-owned lists.
|
|
58
|
+
object.__setattr__(self, "revisions", tuple(self.revisions))
|
|
59
|
+
object.__setattr__(self, "questions", tuple(self.questions))
|
|
60
|
+
require(bool(self.revisions), "Node requires at least one revision")
|
|
61
|
+
for value in self.revisions + self.questions:
|
|
62
|
+
identifier(value)
|
|
63
|
+
require(len(set(self.revisions)) == len(self.revisions), "Duplicate revision")
|
|
64
|
+
require(len(set(self.questions)) == len(self.questions), "Duplicate participation")
|
|
65
|
+
require(type(self.confirmed) is bool, "Expected boolean confirmed")
|
|
66
|
+
require(type(self.archived) is bool, "Expected boolean archived")
|
|
67
|
+
require(not self.archived or self.confirmed, "Only confirmed documents can end use")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Succession:
|
|
72
|
+
"""Record parent -> child across one or more questions, with both text revisions pinned.
|
|
73
|
+
|
|
74
|
+
The note describes what changed. Succession does not imply agreement.
|
|
75
|
+
A proposed edge is valid data but does not retire its parent as a terminal."""
|
|
76
|
+
|
|
77
|
+
id: str
|
|
78
|
+
parent: RevisionRef
|
|
79
|
+
child: RevisionRef
|
|
80
|
+
questions: tuple
|
|
81
|
+
note: str
|
|
82
|
+
confirmed: bool = True
|
|
83
|
+
|
|
84
|
+
def __post_init__(self):
|
|
85
|
+
require(not isinstance(self.questions, str), "Questions must be a sequence, not a string")
|
|
86
|
+
object.__setattr__(self, "questions", tuple(self.questions))
|
|
87
|
+
require(bool(self.questions), "Succession requires a question scope")
|
|
88
|
+
require(len(set(self.questions)) == len(self.questions), "Duplicate question scope")
|
|
89
|
+
for value in (self.id, self.note) + self.questions:
|
|
90
|
+
identifier(value)
|
|
91
|
+
require(
|
|
92
|
+
isinstance(self.parent, RevisionRef) and isinstance(self.child, RevisionRef), "Expected revision references"
|
|
93
|
+
)
|
|
94
|
+
require(type(self.confirmed) is bool, "Expected boolean confirmed")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class GraphView:
|
|
99
|
+
"""Return the confirmed graph for one question, including its global boundaries.
|
|
100
|
+
|
|
101
|
+
Nodes, roots, and terminals contain document IDs; edges contain Succession values.
|
|
102
|
+
A terminal is structural, not an automatic declaration of a representative position."""
|
|
103
|
+
|
|
104
|
+
question: str
|
|
105
|
+
nodes: tuple
|
|
106
|
+
edges: tuple
|
|
107
|
+
roots: tuple
|
|
108
|
+
terminals: tuple
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Validate and query immutable, question-scoped document lineage.
|
|
2
|
+
|
|
3
|
+
Construction enforces invariants once for each new value. Read queries filter to
|
|
4
|
+
confirmed relationships; add/confirm operations build a new validated value.
|
|
5
|
+
There is no database, file access, publication policy, or UI dependency here."""
|
|
6
|
+
|
|
7
|
+
from dataclasses import asdict, dataclass, replace
|
|
8
|
+
|
|
9
|
+
from .contracts import GraphView, Node, RevisionRef, Succession, identifier, require
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Genealogy:
|
|
14
|
+
"""Hold all document nodes and question-labeled succession edges.
|
|
15
|
+
|
|
16
|
+
Each document pair has at most one edge, carrying a set of question scopes.
|
|
17
|
+
The union of all scopes must be acyclic, including proposed edges."""
|
|
18
|
+
|
|
19
|
+
nodes: tuple
|
|
20
|
+
questions: tuple
|
|
21
|
+
edges: tuple = ()
|
|
22
|
+
|
|
23
|
+
def __post_init__(self):
|
|
24
|
+
"""Validate local membership, edge references, uniqueness, and the global DAG."""
|
|
25
|
+
for field in ("nodes", "questions", "edges"):
|
|
26
|
+
object.__setattr__(self, field, tuple(getattr(self, field)))
|
|
27
|
+
for q in self.questions:
|
|
28
|
+
identifier(q)
|
|
29
|
+
require(len(set(self.questions)) == len(self.questions), "Duplicate question")
|
|
30
|
+
require(all(isinstance(n, Node) for n in self.nodes), "Expected nodes")
|
|
31
|
+
nodes = {n.id: n for n in self.nodes}
|
|
32
|
+
require(len(nodes) == len(self.nodes), "Duplicate node")
|
|
33
|
+
for n in self.nodes:
|
|
34
|
+
require(set(n.questions) <= set(self.questions), "Unknown question")
|
|
35
|
+
# A relationship is unique by document pair, regardless of its question scopes.
|
|
36
|
+
ids, pairs = set(), set()
|
|
37
|
+
adjacency = {n: set() for n in nodes}
|
|
38
|
+
for e in self.edges:
|
|
39
|
+
require(isinstance(e, Succession), "Expected succession")
|
|
40
|
+
require(e.id not in ids, "Duplicate edge ID")
|
|
41
|
+
ids.add(e.id)
|
|
42
|
+
require(e.parent.document != e.child.document, "Self succession is not allowed")
|
|
43
|
+
require(set(e.questions) <= set(self.questions), "Unknown question")
|
|
44
|
+
for ref in (e.parent, e.child):
|
|
45
|
+
require(ref.document in nodes, "Unknown edge endpoint")
|
|
46
|
+
n = nodes[ref.document]
|
|
47
|
+
require(ref.revision in n.revisions, "Unknown edge revision")
|
|
48
|
+
require(set(e.questions) <= set(n.questions), "Endpoint must participate in every scope")
|
|
49
|
+
require(not e.confirmed or n.confirmed, "Confirmed edge needs confirmed endpoints")
|
|
50
|
+
pair = (e.parent.document, e.child.document)
|
|
51
|
+
require(pair not in pairs, "Only one succession per document pair is allowed")
|
|
52
|
+
pairs.add(pair)
|
|
53
|
+
# Question overlap never creates another edge between this document pair.
|
|
54
|
+
adjacency[e.parent.document].add(e.child.document)
|
|
55
|
+
# Kahn traversal avoids recursion limits on long corpora. Proposals also must be acyclic.
|
|
56
|
+
degree = {n: 0 for n in nodes}
|
|
57
|
+
for children in adjacency.values():
|
|
58
|
+
for child in children:
|
|
59
|
+
degree[child] += 1
|
|
60
|
+
# Repeatedly remove nodes with no remaining predecessors. A cycle leaves
|
|
61
|
+
# nodes that can never become ready, so the visited count will be too small.
|
|
62
|
+
ready = [n for n, d in degree.items() if d == 0]
|
|
63
|
+
count = 0
|
|
64
|
+
while ready:
|
|
65
|
+
n = ready.pop()
|
|
66
|
+
count += 1
|
|
67
|
+
for child in adjacency[n]:
|
|
68
|
+
degree[child] -= 1
|
|
69
|
+
if degree[child] == 0:
|
|
70
|
+
ready.append(child)
|
|
71
|
+
require(count == len(nodes), "Genealogy must be acyclic")
|
|
72
|
+
|
|
73
|
+
def view(self, question):
|
|
74
|
+
"""Derive roots and terminals from confirmed edges for exactly one question.
|
|
75
|
+
|
|
76
|
+
Sorting makes output deterministic; it does not rank importance or recency."""
|
|
77
|
+
require(question in self.questions, "Unknown question")
|
|
78
|
+
nodes = tuple(sorted(n.id for n in self.nodes if n.confirmed and question in n.questions))
|
|
79
|
+
edges = tuple(sorted((e for e in self.edges if e.confirmed and question in e.questions), key=lambda e: e.id))
|
|
80
|
+
# Compute boundaries after filtering by question and confirmation state.
|
|
81
|
+
# The same document can therefore be historical in one question and current in another.
|
|
82
|
+
incoming = {e.child.document for e in edges}
|
|
83
|
+
outgoing = {e.parent.document for e in edges}
|
|
84
|
+
return GraphView(
|
|
85
|
+
question,
|
|
86
|
+
nodes,
|
|
87
|
+
edges,
|
|
88
|
+
tuple(n for n in nodes if n not in incoming),
|
|
89
|
+
tuple(n for n in nodes if n not in outgoing),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _walk(self, document, question, direction, depth=None):
|
|
93
|
+
"""Traverse the confirmed question graph breadth-first.
|
|
94
|
+
|
|
95
|
+
The starting document is excluded from the result. A finite depth counts hops;
|
|
96
|
+
None means all reachable nodes. The visited set prevents duplicate work at merges."""
|
|
97
|
+
view = self.view(question)
|
|
98
|
+
require(document in view.nodes, "Document not in confirmed question graph")
|
|
99
|
+
require(depth is None or (type(depth) is int and depth >= 0), "Invalid depth")
|
|
100
|
+
adjacency = {n: set() for n in view.nodes}
|
|
101
|
+
for e in view.edges:
|
|
102
|
+
p, c = e.parent.document, e.child.document
|
|
103
|
+
if direction in ("forward", "both"):
|
|
104
|
+
adjacency[p].add(c)
|
|
105
|
+
if direction in ("backward", "both"):
|
|
106
|
+
adjacency[c].add(p)
|
|
107
|
+
# frontier is one hop layer; subtracting seen handles converging branches.
|
|
108
|
+
seen, frontier, level = {document}, {document}, 0
|
|
109
|
+
while frontier and (depth is None or level < depth):
|
|
110
|
+
frontier = set().union(*(adjacency[n] for n in frontier)) - seen
|
|
111
|
+
seen.update(frontier)
|
|
112
|
+
level += 1
|
|
113
|
+
return tuple(sorted(seen - {document}))
|
|
114
|
+
|
|
115
|
+
def ancestors(self, document, question):
|
|
116
|
+
"""Return every confirmed predecessor reachable within the question."""
|
|
117
|
+
return self._walk(document, question, "backward")
|
|
118
|
+
|
|
119
|
+
def descendants(self, document, question):
|
|
120
|
+
"""Return every confirmed successor reachable within the question."""
|
|
121
|
+
return self._walk(document, question, "forward")
|
|
122
|
+
|
|
123
|
+
def neighborhood(self, document, question, radius=1):
|
|
124
|
+
"""Return nearby nodes and the edges between them, walking in both directions.
|
|
125
|
+
|
|
126
|
+
This is an induced local slice. Its boundary nodes are not necessarily the roots
|
|
127
|
+
or terminals of the full question graph."""
|
|
128
|
+
nodes = tuple(sorted((document,) + self._walk(document, question, "both", radius)))
|
|
129
|
+
edges = tuple(e for e in self.view(question).edges if e.parent.document in nodes and e.child.document in nodes)
|
|
130
|
+
# This is a local slice, not a claim that its boundary nodes are global roots/terminals.
|
|
131
|
+
return {"question": question, "nodes": nodes, "edges": edges}
|
|
132
|
+
|
|
133
|
+
def connected_components(self, documents, include_proposed=False):
|
|
134
|
+
"""Return complete weak components touched by the selected document IDs.
|
|
135
|
+
|
|
136
|
+
Direction is ignored for reachability only; original edges retain direction.
|
|
137
|
+
All questions participate. Archived nodes remain; unconfirmed edges are
|
|
138
|
+
excluded by default, so a selected draft may be an isolated component.
|
|
139
|
+
"""
|
|
140
|
+
require(not isinstance(documents, str), "Document IDs must be a sequence")
|
|
141
|
+
selected = set(documents)
|
|
142
|
+
adjacency = {node.id: set() for node in self.nodes}
|
|
143
|
+
require(selected <= set(adjacency), "Unknown document")
|
|
144
|
+
edges = tuple(e for e in self.edges if include_proposed or e.confirmed)
|
|
145
|
+
for edge in edges:
|
|
146
|
+
parent, child = edge.parent.document, edge.child.document
|
|
147
|
+
adjacency[parent].add(child)
|
|
148
|
+
adjacency[child].add(parent)
|
|
149
|
+
visited, result = set(), []
|
|
150
|
+
for start in sorted(selected):
|
|
151
|
+
if start in visited:
|
|
152
|
+
continue
|
|
153
|
+
component, frontier = {start}, [start]
|
|
154
|
+
while frontier:
|
|
155
|
+
current = frontier.pop()
|
|
156
|
+
for neighbor in adjacency[current] - component:
|
|
157
|
+
component.add(neighbor)
|
|
158
|
+
frontier.append(neighbor)
|
|
159
|
+
visited.update(component)
|
|
160
|
+
result.append(
|
|
161
|
+
{"nodes": tuple(sorted(component)), "edges": tuple(e for e in edges if e.parent.document in component)}
|
|
162
|
+
)
|
|
163
|
+
return tuple(result)
|
|
164
|
+
|
|
165
|
+
def add_node(self, node):
|
|
166
|
+
"""Return a new graph with this node; validation runs again during replacement."""
|
|
167
|
+
return replace(self, nodes=self.nodes + (node,))
|
|
168
|
+
|
|
169
|
+
def add_edge(self, edge):
|
|
170
|
+
"""Return a new graph with this relationship, rejecting invalid references or cycles."""
|
|
171
|
+
parent = next((n for n in self.nodes if n.id == edge.parent.document), None)
|
|
172
|
+
require(parent is not None and not parent.archived, "Archived document cannot be a new parent")
|
|
173
|
+
return replace(self, edges=self.edges + (edge,))
|
|
174
|
+
|
|
175
|
+
def set_archived(self, document, archived=True):
|
|
176
|
+
"""Explicitly end or restore future use without changing historical lineage."""
|
|
177
|
+
require(any(n.id == document for n in self.nodes), "Unknown document")
|
|
178
|
+
require(type(archived) is bool, "Expected boolean archived")
|
|
179
|
+
return replace(self, nodes=tuple(replace(n, archived=archived) if n.id == document else n for n in self.nodes))
|
|
180
|
+
|
|
181
|
+
def available_documents(self):
|
|
182
|
+
"""Return document IDs eligible for new use, independently of question terminals."""
|
|
183
|
+
return tuple(sorted(n.id for n in self.nodes if not n.archived))
|
|
184
|
+
|
|
185
|
+
def confirm_document(self, document, revision, archive_parents=()):
|
|
186
|
+
"""Confirm a document and its incoming proposals at the selected revision.
|
|
187
|
+
|
|
188
|
+
Newly confirmed edges pin the chosen child revision. Already confirmed edges keep
|
|
189
|
+
the historical revision they originally referenced. The input graph is unchanged."""
|
|
190
|
+
node = next((n for n in self.nodes if n.id == document), None)
|
|
191
|
+
require(node is not None and revision in node.revisions, "Unknown document/revision")
|
|
192
|
+
require(not node.archived, "Restore an archived document before confirming new use")
|
|
193
|
+
require(not isinstance(archive_parents, str), "Parent IDs must be a sequence")
|
|
194
|
+
archive_parents = tuple(archive_parents)
|
|
195
|
+
require(len(set(archive_parents)) == len(archive_parents), "Duplicate archive parent")
|
|
196
|
+
incoming = tuple(e for e in self.edges if e.child.document == document)
|
|
197
|
+
require(
|
|
198
|
+
set(archive_parents) <= {e.parent.document for e in incoming}, "Can only archive direct succession parents"
|
|
199
|
+
)
|
|
200
|
+
archived = {n.id for n in self.nodes if n.archived}
|
|
201
|
+
require(
|
|
202
|
+
not any(not e.confirmed and e.parent.document in archived for e in incoming),
|
|
203
|
+
"Archived document cannot be used by a pending succession; restore it first",
|
|
204
|
+
)
|
|
205
|
+
# dataclasses.replace constructs a new instance and re-runs __post_init__,
|
|
206
|
+
# so confirmation cannot bypass reference or cycle validation.
|
|
207
|
+
return replace(
|
|
208
|
+
self,
|
|
209
|
+
nodes=tuple(
|
|
210
|
+
replace(n, confirmed=True)
|
|
211
|
+
if n.id == document
|
|
212
|
+
else replace(n, archived=True)
|
|
213
|
+
if n.id in archive_parents
|
|
214
|
+
else n
|
|
215
|
+
for n in self.nodes
|
|
216
|
+
),
|
|
217
|
+
edges=tuple(
|
|
218
|
+
replace(e, confirmed=True, child=RevisionRef(document, revision) if not e.confirmed else e.child)
|
|
219
|
+
if e.child.document == document
|
|
220
|
+
else e
|
|
221
|
+
for e in self.edges
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def to_dict(self):
|
|
226
|
+
"""Produce detached, JSON-serializable values with an explicit schema version."""
|
|
227
|
+
return {"schema_version": 3, **asdict(self)}
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def from_dict(cls, data):
|
|
231
|
+
"""Rebuild value objects from decoded JSON and run constructor validation."""
|
|
232
|
+
require(data.get("schema_version") in (2, 3), "Unsupported genealogy schema")
|
|
233
|
+
if data["schema_version"] == 3:
|
|
234
|
+
require(all("archived" in n for n in data["nodes"]), "Missing document use state")
|
|
235
|
+
return cls(
|
|
236
|
+
tuple(Node(**n) for n in data["nodes"]),
|
|
237
|
+
tuple(data["questions"]),
|
|
238
|
+
tuple(
|
|
239
|
+
Succession(**{**e, "parent": RevisionRef(**e["parent"]), "child": RevisionRef(**e["child"])})
|
|
240
|
+
for e in data["edges"]
|
|
241
|
+
),
|
|
242
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stemma-graph
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Portable question-scoped genealogy graphs: a stemma for your own writing
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# stemma-graph
|
|
12
|
+
|
|
13
|
+
A standalone Python package for question-scoped genealogy graphs. The import
|
|
14
|
+
name is `stemma_graph`.
|
|
15
|
+
|
|
16
|
+
It has no runtime dependencies and needs no Stemma, Git, files, data or
|
|
17
|
+
interface — if all you want is the graph, this is the only piece you need.
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
python3.12 -m venv .venv
|
|
21
|
+
.venv/bin/pip install ./packages/stemma_graph
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Install into a virtual environment rather than a global interpreter. Once the
|
|
25
|
+
package is published this becomes `pip install stemma-graph`; until then,
|
|
26
|
+
install the directory as above. It is self-contained, so it can also be copied
|
|
27
|
+
into another project and installed from there.
|
|
28
|
+
|
|
29
|
+
To try it without installing anything, from the repository root:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
python3.12 -B scripts/dev.py example
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## The model
|
|
36
|
+
|
|
37
|
+
A `Genealogy` is a directed acyclic graph with **one edge per parent/child
|
|
38
|
+
pair**; the questions that succession covers live in that edge's `questions`
|
|
39
|
+
list, so adding finer questions never multiplies edges. Self-edges and cycles
|
|
40
|
+
are refused.
|
|
41
|
+
|
|
42
|
+
`Node.archived` marks a document retired from future use as material.
|
|
43
|
+
`Genealogy.set_archived` returns a new graph, and `confirm_document` takes
|
|
44
|
+
`archive_parents` so a parent can be retired as a succession is confirmed.
|
|
45
|
+
Retired documents remain in past lineage and in terminal calculations;
|
|
46
|
+
`available_documents` lists the ones still usable.
|
|
47
|
+
|
|
48
|
+
Every change returns a new immutable graph. Nothing is modified in place.
|
|
49
|
+
|
|
50
|
+
## Versions
|
|
51
|
+
|
|
52
|
+
The package version is 0.3.0 and the genealogy JSON schema is v3; they version
|
|
53
|
+
different things. Similarity graphs and exploration were removed in 0.3.0.
|
|
54
|
+
|
|
55
|
+
See the repository [README](https://github.com/byeongsuyu/stemma/blob/main/README.md) and
|
|
56
|
+
[design notes](https://github.com/byeongsuyu/stemma/blob/main/docs/design.md).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
stemma_graph/__init__.py,sha256=ue_xBkD0g7ZJ42RzTRptLZzg2YaCUDgwg4wnFnn1KCc,294
|
|
2
|
+
stemma_graph/contracts.py,sha256=Njea6FDF69T_t3QELC8GHWtk18ckZXO5sZVcLe3Q-8k,4028
|
|
3
|
+
stemma_graph/genealogy.py,sha256=H8ZwwLU-dRWeLTVNf_FBsKDKYwPCCzesz1EVRyHlXy0,12357
|
|
4
|
+
stemma_graph-0.3.0.dist-info/licenses/LICENSE,sha256=ApJA0qNZXt4SRjI02D7p_ZJkumbsv1ZVKf_31vVgqdc,1068
|
|
5
|
+
stemma_graph-0.3.0.dist-info/METADATA,sha256=G_kcI4vqnPIkYcpf7uiBS0Km339AoWHT-RHgvPc7uIw,2027
|
|
6
|
+
stemma_graph-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
stemma_graph-0.3.0.dist-info/top_level.txt,sha256=pwWqC_XPy79PUD1i8duxBqv6zNfrnImKIrf0EMUW0Nc,13
|
|
8
|
+
stemma_graph-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Byeongsu Yu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stemma_graph
|