lp2graph 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.
- lp2graph/__init__.py +54 -0
- lp2graph/cli.py +238 -0
- lp2graph/codec/__init__.py +41 -0
- lp2graph/codec/latex.py +884 -0
- lp2graph/codec/normalize.py +82 -0
- lp2graph/core/__init__.py +35 -0
- lp2graph/core/graph.py +183 -0
- lp2graph/core/loader.py +63 -0
- lp2graph/core/model.py +437 -0
- lp2graph/core/validate.py +237 -0
- lp2graph/export/__init__.py +13 -0
- lp2graph/export/dgl.py +51 -0
- lp2graph/export/latex.py +126 -0
- lp2graph/export/networkx_adapter.py +50 -0
- lp2graph/export/pyg.py +79 -0
- lp2graph/export/pyomo_stub.py +81 -0
- lp2graph/metrics/__init__.py +58 -0
- lp2graph/metrics/classification.py +113 -0
- lp2graph/metrics/flags.py +122 -0
- lp2graph/metrics/result.py +26 -0
- lp2graph/metrics/structural.py +236 -0
- lp2graph/mining/__init__.py +47 -0
- lp2graph/mining/cluster/__init__.py +65 -0
- lp2graph/mining/cluster/agglomerative.py +82 -0
- lp2graph/mining/cluster/distance.py +65 -0
- lp2graph/mining/cluster/operator.py +218 -0
- lp2graph/mining/cluster/silhouette.py +88 -0
- lp2graph/mining/cluster/stability.py +178 -0
- lp2graph/mining/cluster/taxonomy.py +268 -0
- lp2graph/mining/corpusmgr/__init__.py +70 -0
- lp2graph/mining/corpusmgr/dedup.py +183 -0
- lp2graph/mining/corpusmgr/manager.py +79 -0
- lp2graph/mining/corpusmgr/manifest.py +82 -0
- lp2graph/mining/corpusmgr/record.py +101 -0
- lp2graph/mining/corpusmgr/select.py +128 -0
- lp2graph/mining/homologize/__init__.py +82 -0
- lp2graph/mining/homologize/concept.py +134 -0
- lp2graph/mining/homologize/entity.py +217 -0
- lp2graph/mining/homologize/lemmatize.py +80 -0
- lp2graph/mining/homologize/signature.py +166 -0
- lp2graph/mining/homologize/thesaurus.py +70 -0
- lp2graph/mining/homologize/tokenize.py +255 -0
- lp2graph/mining/homologize/vectorize.py +141 -0
- lp2graph/mining/ingest/__init__.py +59 -0
- lp2graph/mining/ingest/code_importers.py +104 -0
- lp2graph/mining/ingest/dispatch.py +148 -0
- lp2graph/mining/ingest/latex_normalizer.py +243 -0
- lp2graph/mining/ingest/pyomo_importer.py +297 -0
- lp2graph/mining/ingest/result.py +124 -0
- lp2graph/mining/isomorphism/__init__.py +26 -0
- lp2graph/mining/isomorphism/report.py +178 -0
- lp2graph/mining/label/__init__.py +70 -0
- lp2graph/mining/label/classifier.py +161 -0
- lp2graph/mining/label/features.py +35 -0
- lp2graph/mining/label/guardrails.py +176 -0
- lp2graph/mining/label/loop.py +314 -0
- lp2graph/mining/label/rules.py +92 -0
- lp2graph/mining/label/store.py +164 -0
- lp2graph/mining/label/vocab.py +64 -0
- lp2graph/mining/provenance.py +90 -0
- lp2graph/mining/versions.py +51 -0
- lp2graph/nl/__init__.py +15 -0
- lp2graph/nl/describe.py +301 -0
- lp2graph/render/__init__.py +11 -0
- lp2graph/render/palette.py +80 -0
- lp2graph/render/svg.py +220 -0
- lp2graph/solve/__init__.py +50 -0
- lp2graph/solve/grounder.py +405 -0
- lp2graph/solve/instance.py +76 -0
- lp2graph/transform/__init__.py +30 -0
- lp2graph/transform/bigm.py +173 -0
- lp2graph/views/__init__.py +17 -0
- lp2graph/views/ground.py +477 -0
- lp2graph/views/hybrid.py +202 -0
- lp2graph/views/schema.py +208 -0
- lp2graph-0.3.0.dist-info/METADATA +206 -0
- lp2graph-0.3.0.dist-info/RECORD +80 -0
- lp2graph-0.3.0.dist-info/WHEEL +4 -0
- lp2graph-0.3.0.dist-info/entry_points.txt +2 -0
- lp2graph-0.3.0.dist-info/licenses/LICENSE +205 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Canonical normal form for round-trip comparison.
|
|
2
|
+
|
|
3
|
+
The LaTeX codec preserves the mathematical model exactly but normalizes a
|
|
4
|
+
small set of *incidental* fields that have no algebraic surface form and
|
|
5
|
+
no effect on grounding/solving:
|
|
6
|
+
|
|
7
|
+
- A literal term's ``ref`` name (e.g. ``"one"``, ``"_const"``) is folded
|
|
8
|
+
to ``"_const"`` — only its numeric ``coefficient`` matters.
|
|
9
|
+
- A binding's ``offset`` is recomputed from its own ``expr`` so a stored
|
|
10
|
+
offset that disagrees with the expression text is corrected.
|
|
11
|
+
- ``coefficient`` ``None`` is folded to ``1`` (the schema default).
|
|
12
|
+
- A negative numeric ``coefficient`` is folded into ``sign`` (the LaTeX
|
|
13
|
+
surface carries the sign explicitly, the coefficient magnitude bare).
|
|
14
|
+
- A term's ``role`` is folded to the default for the side it sits on
|
|
15
|
+
(``lhs``/``rhs``/``objective``). ``role`` only drives edge coloring in
|
|
16
|
+
rendered graphs; it has no algebraic surface form and no effect on
|
|
17
|
+
grounding or solving.
|
|
18
|
+
|
|
19
|
+
Two formulations that share a canonical normal form are
|
|
20
|
+
solve-equivalent and structurally identical up to these labels.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
from lp2graph.core.model import Binding, Formulation, Term
|
|
28
|
+
|
|
29
|
+
_OFFSET_RE = re.compile(r"[+-]\s*\d+\s*$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _norm_offset_from_expr(expr: str) -> int:
|
|
33
|
+
m = _OFFSET_RE.search(expr.replace(" ", ""))
|
|
34
|
+
if not m:
|
|
35
|
+
return 0
|
|
36
|
+
return int(m.group(0).replace(" ", ""))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _norm_binding(b: Binding) -> Binding:
|
|
40
|
+
return b.model_copy(update={"offset": _norm_offset_from_expr(b.expr)})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_SIDE_DEFAULT = {"lhs": "lhs", "rhs": "rhs"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _norm_term(t: Term, side: str) -> Term:
|
|
47
|
+
update: dict[str, object] = {
|
|
48
|
+
"bindings": tuple(_norm_binding(b) for b in t.bindings),
|
|
49
|
+
"role": _SIDE_DEFAULT.get(side, side),
|
|
50
|
+
}
|
|
51
|
+
coef = 1 if t.coefficient is None else t.coefficient
|
|
52
|
+
sign = t.sign
|
|
53
|
+
if isinstance(coef, (int, float)) and not isinstance(coef, bool) and coef < 0:
|
|
54
|
+
coef = -coef
|
|
55
|
+
sign = -sign
|
|
56
|
+
update["coefficient"] = coef
|
|
57
|
+
update["sign"] = sign
|
|
58
|
+
if t.ref_kind == "literal":
|
|
59
|
+
update["ref"] = "_const"
|
|
60
|
+
return t.model_copy(update=update)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def canonical_normal_form(f: Formulation) -> Formulation:
|
|
64
|
+
"""Return ``f`` with incidental labels normalized (see module docstring)."""
|
|
65
|
+
constraints = tuple(
|
|
66
|
+
c.model_copy(
|
|
67
|
+
update={
|
|
68
|
+
"lhs": tuple(_norm_term(t, "lhs") for t in c.lhs),
|
|
69
|
+
"rhs": tuple(_norm_term(t, "rhs") for t in c.rhs),
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
for c in f.constraints
|
|
73
|
+
)
|
|
74
|
+
objective = None
|
|
75
|
+
if f.objective is not None:
|
|
76
|
+
objective = f.objective.model_copy(
|
|
77
|
+
update={"terms": tuple(_norm_term(t, "objective") for t in f.objective.terms)}
|
|
78
|
+
)
|
|
79
|
+
return f.model_copy(update={"constraints": constraints, "objective": objective})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["canonical_normal_form"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Core canonical model, loader, validator, and internal graph type."""
|
|
2
|
+
|
|
3
|
+
from lp2graph.core.graph import Edge, Graph, Node
|
|
4
|
+
from lp2graph.core.loader import load, loads
|
|
5
|
+
from lp2graph.core.model import (
|
|
6
|
+
Binding,
|
|
7
|
+
ConstraintTemplate,
|
|
8
|
+
Formulation,
|
|
9
|
+
Index,
|
|
10
|
+
Objective,
|
|
11
|
+
Parameter,
|
|
12
|
+
Quantifier,
|
|
13
|
+
Term,
|
|
14
|
+
VariableTemplate,
|
|
15
|
+
)
|
|
16
|
+
from lp2graph.core.validate import ValidationError, validate
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Binding",
|
|
20
|
+
"ConstraintTemplate",
|
|
21
|
+
"Edge",
|
|
22
|
+
"Formulation",
|
|
23
|
+
"Graph",
|
|
24
|
+
"Index",
|
|
25
|
+
"Node",
|
|
26
|
+
"Objective",
|
|
27
|
+
"Parameter",
|
|
28
|
+
"Quantifier",
|
|
29
|
+
"Term",
|
|
30
|
+
"ValidationError",
|
|
31
|
+
"VariableTemplate",
|
|
32
|
+
"load",
|
|
33
|
+
"loads",
|
|
34
|
+
"validate",
|
|
35
|
+
]
|
lp2graph/core/graph.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Internal typed graph used by view derivations, metrics, render, and export. # noqa: E501
|
|
2
|
+
|
|
3
|
+
This is *not* a NetworkX, PyG, or DGL graph. It is a small, library-agnostic
|
|
4
|
+
representation that downstream consumers translate into their own format.
|
|
5
|
+
|
|
6
|
+
A :class:`Graph` is a directed multigraph with typed nodes and typed edges.
|
|
7
|
+
Nodes carry a ``cls`` (class), ``subtype``, ``shape`` (the index families
|
|
8
|
+
they range over, in the schema view), and a free-form ``data`` dict for
|
|
9
|
+
view-specific metadata. Edges carry a ``type``, ``role``, optional
|
|
10
|
+
``label``, and ``data``.
|
|
11
|
+
|
|
12
|
+
Determinism: node and edge insertion order is preserved. Equality compares
|
|
13
|
+
nodes and edges as ordered sequences. This guarantees identical render and
|
|
14
|
+
export output across runs given identical inputs.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterable, Mapping
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
|
|
23
|
+
NodeClass = Literal[
|
|
24
|
+
"variable",
|
|
25
|
+
"constraint",
|
|
26
|
+
"objective",
|
|
27
|
+
"index",
|
|
28
|
+
"parameter",
|
|
29
|
+
"operator",
|
|
30
|
+
"instance_variable",
|
|
31
|
+
"instance_constraint",
|
|
32
|
+
]
|
|
33
|
+
"""High-level node category. Drives palette selection in the renderer."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
EdgeType = Literal[
|
|
37
|
+
"var_in_constraint",
|
|
38
|
+
"var_in_objective",
|
|
39
|
+
"uses_index",
|
|
40
|
+
"uses_parameter",
|
|
41
|
+
"operator_input",
|
|
42
|
+
"operator_output",
|
|
43
|
+
"instance_of",
|
|
44
|
+
"ground_var_in_constraint",
|
|
45
|
+
]
|
|
46
|
+
"""Edge category. Drives stroke/style in the renderer."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class Node:
|
|
51
|
+
"""A node in the typed graph.
|
|
52
|
+
|
|
53
|
+
``id`` must be unique within the graph. Inserting a node with a
|
|
54
|
+
duplicate id raises :class:`ValueError`. ``cls`` selects the visual
|
|
55
|
+
class; ``subtype`` is a free string used by renderers (e.g.
|
|
56
|
+
``"binary"`` for a binary variable).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
id: str
|
|
60
|
+
cls: NodeClass
|
|
61
|
+
subtype: str = ""
|
|
62
|
+
label: str = ""
|
|
63
|
+
shape: tuple[str, ...] = ()
|
|
64
|
+
data: Mapping[str, Any] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class Edge:
|
|
69
|
+
"""A directed edge in the typed graph."""
|
|
70
|
+
|
|
71
|
+
src: str
|
|
72
|
+
dst: str
|
|
73
|
+
type: EdgeType
|
|
74
|
+
role: str = ""
|
|
75
|
+
label: str = ""
|
|
76
|
+
data: Mapping[str, Any] = field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Graph:
|
|
80
|
+
"""A directed multigraph with insertion-order determinism.
|
|
81
|
+
|
|
82
|
+
Not thread-safe. Designed for single-pass construction inside a view
|
|
83
|
+
derivation, then read-only consumption by metrics, renderers, and
|
|
84
|
+
exporters.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
__slots__ = ("_edges", "_nodes", "_view")
|
|
88
|
+
|
|
89
|
+
def __init__(self, view: str = "") -> None:
|
|
90
|
+
self._nodes: dict[str, Node] = {}
|
|
91
|
+
self._edges: list[Edge] = []
|
|
92
|
+
self._view = view
|
|
93
|
+
|
|
94
|
+
# -- accessors --------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def view(self) -> str:
|
|
98
|
+
"""The view this graph was derived from (``"schema"``, etc.)."""
|
|
99
|
+
return self._view
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def nodes(self) -> tuple[Node, ...]:
|
|
103
|
+
return tuple(self._nodes.values())
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def edges(self) -> tuple[Edge, ...]:
|
|
107
|
+
return tuple(self._edges)
|
|
108
|
+
|
|
109
|
+
def node(self, node_id: str) -> Node:
|
|
110
|
+
return self._nodes[node_id]
|
|
111
|
+
|
|
112
|
+
def has_node(self, node_id: str) -> bool:
|
|
113
|
+
return node_id in self._nodes
|
|
114
|
+
|
|
115
|
+
def __len__(self) -> int:
|
|
116
|
+
return len(self._nodes)
|
|
117
|
+
|
|
118
|
+
# -- mutation ---------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def add_node(
|
|
121
|
+
self,
|
|
122
|
+
node_id: str,
|
|
123
|
+
cls: NodeClass,
|
|
124
|
+
*,
|
|
125
|
+
subtype: str = "",
|
|
126
|
+
label: str = "",
|
|
127
|
+
shape: Iterable[str] = (),
|
|
128
|
+
data: Mapping[str, Any] | None = None,
|
|
129
|
+
) -> Node:
|
|
130
|
+
if node_id in self._nodes:
|
|
131
|
+
raise ValueError(f"duplicate node id: {node_id!r}")
|
|
132
|
+
node = Node(
|
|
133
|
+
id=node_id,
|
|
134
|
+
cls=cls,
|
|
135
|
+
subtype=subtype,
|
|
136
|
+
label=label or node_id,
|
|
137
|
+
shape=tuple(shape),
|
|
138
|
+
data=dict(data or {}),
|
|
139
|
+
)
|
|
140
|
+
self._nodes[node_id] = node
|
|
141
|
+
return node
|
|
142
|
+
|
|
143
|
+
def add_edge(
|
|
144
|
+
self,
|
|
145
|
+
src: str,
|
|
146
|
+
dst: str,
|
|
147
|
+
type: EdgeType,
|
|
148
|
+
*,
|
|
149
|
+
role: str = "",
|
|
150
|
+
label: str = "",
|
|
151
|
+
data: Mapping[str, Any] | None = None,
|
|
152
|
+
) -> Edge:
|
|
153
|
+
if src not in self._nodes:
|
|
154
|
+
raise KeyError(f"unknown source node: {src!r}")
|
|
155
|
+
if dst not in self._nodes:
|
|
156
|
+
raise KeyError(f"unknown destination node: {dst!r}")
|
|
157
|
+
edge = Edge(
|
|
158
|
+
src=src,
|
|
159
|
+
dst=dst,
|
|
160
|
+
type=type,
|
|
161
|
+
role=role,
|
|
162
|
+
label=label,
|
|
163
|
+
data=dict(data or {}),
|
|
164
|
+
)
|
|
165
|
+
self._edges.append(edge)
|
|
166
|
+
return edge
|
|
167
|
+
|
|
168
|
+
# -- introspection ----------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def nodes_by_class(self, cls: NodeClass) -> tuple[Node, ...]:
|
|
171
|
+
return tuple(n for n in self._nodes.values() if n.cls == cls)
|
|
172
|
+
|
|
173
|
+
def edges_by_type(self, type: EdgeType) -> tuple[Edge, ...]:
|
|
174
|
+
return tuple(e for e in self._edges if e.type == type)
|
|
175
|
+
|
|
176
|
+
def out_edges(self, node_id: str) -> tuple[Edge, ...]:
|
|
177
|
+
return tuple(e for e in self._edges if e.src == node_id)
|
|
178
|
+
|
|
179
|
+
def in_edges(self, node_id: str) -> tuple[Edge, ...]:
|
|
180
|
+
return tuple(e for e in self._edges if e.dst == node_id)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
__all__ = ["Edge", "EdgeType", "Graph", "Node", "NodeClass"]
|
lp2graph/core/loader.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Load and parse formulation files.
|
|
2
|
+
|
|
3
|
+
The loader is intentionally thin: it reads JSON, validates against the
|
|
4
|
+
canonical pydantic model, and returns a :class:`~lp2graph.core.model.Formulation`.
|
|
5
|
+
Schema validation against the JSON Schema runs first to give clear,
|
|
6
|
+
spec-grounded error messages; pydantic then enforces the typed model.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from lp2graph.core.model import Formulation
|
|
16
|
+
from lp2graph.core.validate import validate as _validate
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load(path: str | Path) -> Formulation:
|
|
20
|
+
"""Load a formulation from a JSON file path.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
path: Filesystem path to a JSON file conforming to
|
|
24
|
+
``schema/canonical.schema.json``.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A validated :class:`Formulation`.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValidationError: if the file does not conform to the canonical
|
|
31
|
+
schema or violates a model invariant.
|
|
32
|
+
FileNotFoundError: if ``path`` does not exist.
|
|
33
|
+
"""
|
|
34
|
+
p = Path(path)
|
|
35
|
+
text = p.read_text(encoding="utf-8")
|
|
36
|
+
return loads(text, source=str(p))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def loads(text: str, *, source: str = "<string>") -> Formulation:
|
|
40
|
+
"""Parse a formulation from a JSON string.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
text: JSON document text.
|
|
44
|
+
source: Identifier used in error messages.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A validated :class:`Formulation`.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ValidationError: if the document does not conform.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
data: Any = json.loads(text)
|
|
54
|
+
except json.JSONDecodeError as e:
|
|
55
|
+
from lp2graph.core.validate import ValidationError
|
|
56
|
+
|
|
57
|
+
raise ValidationError(f"{source}: invalid JSON: {e}") from e
|
|
58
|
+
formulation = Formulation.model_validate(data)
|
|
59
|
+
_validate(formulation)
|
|
60
|
+
return formulation
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["load", "loads"]
|