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
lp2graph/export/dgl.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""DGL export (lazy import; stubbed for v0.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from lp2graph.core.graph import Graph
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_dgl(g: Graph) -> Any:
|
|
14
|
+
"""Convert to a DGL heterograph.
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
ImportError: if DGL is not installed.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
import dgl
|
|
21
|
+
import torch
|
|
22
|
+
except ImportError as exc: # pragma: no cover
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"to_dgl requires dgl and torch; install with 'pip install lp2graph[dgl]'"
|
|
25
|
+
) from exc
|
|
26
|
+
|
|
27
|
+
nodes_by_class: dict[str, list[int]] = {}
|
|
28
|
+
id_to_idx: dict[str, tuple[str, int]] = {}
|
|
29
|
+
for i, n in enumerate(g.nodes):
|
|
30
|
+
bucket = nodes_by_class.setdefault(n.cls, [])
|
|
31
|
+
id_to_idx[n.id] = (n.cls, len(bucket))
|
|
32
|
+
bucket.append(i)
|
|
33
|
+
|
|
34
|
+
edge_buckets: dict[tuple[str, str, str], tuple[list[int], list[int]]] = {}
|
|
35
|
+
for edge in g.edges:
|
|
36
|
+
sc, sidx = id_to_idx[edge.src]
|
|
37
|
+
dc, didx = id_to_idx[edge.dst]
|
|
38
|
+
key = (sc, edge.type, dc)
|
|
39
|
+
if key not in edge_buckets:
|
|
40
|
+
edge_buckets[key] = ([], [])
|
|
41
|
+
edge_buckets[key][0].append(sidx)
|
|
42
|
+
edge_buckets[key][1].append(didx)
|
|
43
|
+
|
|
44
|
+
data_dict: dict[tuple[str, str, str], tuple[Any, Any]] = {
|
|
45
|
+
k: (torch.tensor(v[0]), torch.tensor(v[1])) for k, v in edge_buckets.items()
|
|
46
|
+
}
|
|
47
|
+
num_nodes_dict = {k: len(v) for k, v in nodes_by_class.items()}
|
|
48
|
+
return dgl.heterograph(data_dict, num_nodes_dict=num_nodes_dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = ["to_dgl"]
|
lp2graph/export/latex.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""LaTeX export of a formulation's algebraic form.
|
|
2
|
+
|
|
3
|
+
Produces a LaTeX ``align*`` block for the objective and constraints.
|
|
4
|
+
Useful for embedding in papers and for visual inspection of the parsed
|
|
5
|
+
model. Does *not* render the graph — that is what
|
|
6
|
+
:mod:`lp2graph.render` is for.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from lp2graph.core.model import (
|
|
12
|
+
Formulation,
|
|
13
|
+
Quantifier,
|
|
14
|
+
Term,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_CMP = {"le": r"\le", "ge": r"\ge", "eq": "="}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def to_latex(f: Formulation) -> str:
|
|
21
|
+
"""Render the formulation's algebraic form as LaTeX."""
|
|
22
|
+
lines: list[str] = []
|
|
23
|
+
lines.append(r"\begin{align*}")
|
|
24
|
+
if f.objective is not None:
|
|
25
|
+
sense = r"\min" if f.objective.sense == "min" else r"\max"
|
|
26
|
+
body = _render_term_sum(f.objective.terms)
|
|
27
|
+
lines.append(rf" {sense}\quad & {body} \\")
|
|
28
|
+
for c in f.constraints:
|
|
29
|
+
lhs = _render_term_sum(c.lhs)
|
|
30
|
+
rhs = _render_term_sum(c.rhs) if c.rhs else "0"
|
|
31
|
+
cmp = _CMP[c.comparator]
|
|
32
|
+
quant = _render_quantifiers(c.quantifiers)
|
|
33
|
+
suffix = rf" & \quad {quant}" if quant else ""
|
|
34
|
+
lines.append(rf" & {lhs} {cmp} {rhs}{suffix} \\")
|
|
35
|
+
# Variable domains as a final block.
|
|
36
|
+
for v in f.variables:
|
|
37
|
+
shape = _render_shape(v.shape)
|
|
38
|
+
if v.domain == "binary":
|
|
39
|
+
lines.append(rf" & {v.name}{shape} \in \{{0,1\}} \\")
|
|
40
|
+
elif v.domain == "integer":
|
|
41
|
+
lines.append(rf" & {v.name}{shape} \in \mathbb{{Z}} \\")
|
|
42
|
+
elif v.domain == "non_negative":
|
|
43
|
+
lines.append(rf" & {v.name}{shape} \ge 0 \\")
|
|
44
|
+
else:
|
|
45
|
+
lines.append(rf" & {v.name}{shape} \in \mathbb{{R}} \\")
|
|
46
|
+
lines.append(r"\end{align*}")
|
|
47
|
+
return "\n".join(lines)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _render_term_sum(terms: tuple[Term, ...]) -> str:
|
|
51
|
+
if not terms:
|
|
52
|
+
return "0"
|
|
53
|
+
parts: list[str] = []
|
|
54
|
+
for i, t in enumerate(terms):
|
|
55
|
+
s = _render_term(t)
|
|
56
|
+
if i == 0:
|
|
57
|
+
parts.append(("-" + s) if t.sign == -1 else s)
|
|
58
|
+
else:
|
|
59
|
+
sep = "-" if t.sign == -1 else "+"
|
|
60
|
+
parts.append(f"{sep} {s}")
|
|
61
|
+
return " ".join(parts)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _render_term(t: Term) -> str:
|
|
65
|
+
if t.ref_kind == "literal":
|
|
66
|
+
return str(t.coefficient if t.coefficient is not None else 1)
|
|
67
|
+
coef = ""
|
|
68
|
+
if isinstance(t.coefficient, str) or (
|
|
69
|
+
isinstance(t.coefficient, (int, float)) and t.coefficient != 1
|
|
70
|
+
):
|
|
71
|
+
coef = f"{t.coefficient} \\cdot "
|
|
72
|
+
body = t.ref
|
|
73
|
+
if t.bindings:
|
|
74
|
+
idx = ",".join(b.expr for b in t.bindings)
|
|
75
|
+
body = f"{t.ref}_{{{idx}}}"
|
|
76
|
+
if t.operator == "sum":
|
|
77
|
+
sub = ",".join(t.operator_over)
|
|
78
|
+
return rf"\sum_{{{sub}}} {coef}{body}"
|
|
79
|
+
if t.operator == "max":
|
|
80
|
+
return rf"\max\;{coef}{body}"
|
|
81
|
+
if t.operator == "min":
|
|
82
|
+
return rf"\min\;{coef}{body}"
|
|
83
|
+
if t.operator == "abs":
|
|
84
|
+
return rf"\left|{coef}{body}\right|"
|
|
85
|
+
return f"{coef}{body}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _render_quantifiers(quantifiers: tuple[Quantifier, ...]) -> str:
|
|
89
|
+
if not quantifiers:
|
|
90
|
+
return ""
|
|
91
|
+
parts = [rf"\forall {q.index} \in {q.over}" for q in quantifiers]
|
|
92
|
+
restr = []
|
|
93
|
+
for q in quantifiers:
|
|
94
|
+
if q.restriction == "ne_other":
|
|
95
|
+
restr.append(rf"{q.index} \ne {q.restriction_other}")
|
|
96
|
+
elif q.restriction == "lt_other":
|
|
97
|
+
restr.append(rf"{q.index} < {q.restriction_other}")
|
|
98
|
+
elif q.restriction == "le_other":
|
|
99
|
+
restr.append(rf"{q.index} \le {q.restriction_other}")
|
|
100
|
+
elif q.restriction == "gt_other":
|
|
101
|
+
restr.append(rf"{q.index} > {q.restriction_other}")
|
|
102
|
+
elif q.restriction == "ge_other":
|
|
103
|
+
restr.append(rf"{q.index} \ge {q.restriction_other}")
|
|
104
|
+
elif q.restriction == "ordered_pair":
|
|
105
|
+
restr.append(rf"{q.index} < {q.restriction_other}")
|
|
106
|
+
if q.where is not None:
|
|
107
|
+
restr.append(rf"{q.where.parameter}_{{{q.index}}} = {_render_value(q.where.equals)}")
|
|
108
|
+
out = ", ".join(parts)
|
|
109
|
+
if restr:
|
|
110
|
+
out += ", " + ", ".join(restr)
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _render_value(v: bool | int | float | str) -> str:
|
|
115
|
+
if isinstance(v, bool):
|
|
116
|
+
return r"\mathrm{true}" if v else r"\mathrm{false}"
|
|
117
|
+
return str(v)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _render_shape(shape: tuple[str, ...]) -> str:
|
|
121
|
+
if not shape:
|
|
122
|
+
return ""
|
|
123
|
+
return f"_{{{','.join(shape)}}}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = ["to_latex"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""NetworkX export.
|
|
2
|
+
|
|
3
|
+
Converts an internal :class:`~lp2graph.core.graph.Graph` to a
|
|
4
|
+
``networkx.MultiDiGraph``. Node attributes preserve ``cls``, ``subtype``,
|
|
5
|
+
``label``, ``shape``, and ``data``. Edge attributes preserve ``type``,
|
|
6
|
+
``role``, ``label``, ``data``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from lp2graph.core.graph import Graph
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import networkx as nx
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_networkx(g: Graph) -> nx.MultiDiGraph:
|
|
20
|
+
"""Convert to a NetworkX MultiDiGraph."""
|
|
21
|
+
try:
|
|
22
|
+
import networkx as nx
|
|
23
|
+
except ImportError as exc: # pragma: no cover
|
|
24
|
+
raise ImportError(
|
|
25
|
+
"to_networkx requires networkx; install with 'pip install lp2graph[networkx]'"
|
|
26
|
+
) from exc
|
|
27
|
+
|
|
28
|
+
nxg: Any = nx.MultiDiGraph()
|
|
29
|
+
for n in g.nodes:
|
|
30
|
+
nxg.add_node(
|
|
31
|
+
n.id,
|
|
32
|
+
cls=n.cls,
|
|
33
|
+
subtype=n.subtype,
|
|
34
|
+
label=n.label,
|
|
35
|
+
shape=list(n.shape),
|
|
36
|
+
data=dict(n.data),
|
|
37
|
+
)
|
|
38
|
+
for edge in g.edges:
|
|
39
|
+
nxg.add_edge(
|
|
40
|
+
edge.src,
|
|
41
|
+
edge.dst,
|
|
42
|
+
type=edge.type,
|
|
43
|
+
role=edge.role,
|
|
44
|
+
label=edge.label,
|
|
45
|
+
data=dict(edge.data),
|
|
46
|
+
)
|
|
47
|
+
return nxg
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["to_networkx"]
|
lp2graph/export/pyg.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""PyG export.
|
|
2
|
+
|
|
3
|
+
Converts an internal :class:`~lp2graph.core.graph.Graph` to a
|
|
4
|
+
``torch_geometric.data.HeteroData`` instance. Node classes become node
|
|
5
|
+
types; edge types become PyG edge types ``(src_cls, edge_type, dst_cls)``.
|
|
6
|
+
|
|
7
|
+
Node features are minimal in v0.1: a single one-hot of the node's
|
|
8
|
+
subtype. Callers who want richer features should consume the typed
|
|
9
|
+
graph and build their own.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from lp2graph.core.graph import Graph
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from torch_geometric.data import HeteroData
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def to_pyg(g: Graph) -> HeteroData:
|
|
23
|
+
"""Convert to a PyG HeteroData object.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ImportError: if torch and torch_geometric are not installed.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
import torch
|
|
30
|
+
from torch_geometric.data import HeteroData
|
|
31
|
+
except ImportError as exc: # pragma: no cover
|
|
32
|
+
raise ImportError(
|
|
33
|
+
"to_pyg requires torch and torch_geometric; install with 'pip install lp2graph[pyg]'"
|
|
34
|
+
) from exc
|
|
35
|
+
|
|
36
|
+
data: Any = HeteroData()
|
|
37
|
+
|
|
38
|
+
# Group nodes by class; assign per-class indices.
|
|
39
|
+
nodes_by_class: dict[str, list[int]] = {}
|
|
40
|
+
id_to_idx: dict[str, tuple[str, int]] = {}
|
|
41
|
+
subtype_vocab: dict[str, list[str]] = {}
|
|
42
|
+
|
|
43
|
+
for i, n in enumerate(g.nodes):
|
|
44
|
+
bucket = nodes_by_class.setdefault(n.cls, [])
|
|
45
|
+
idx = len(bucket)
|
|
46
|
+
bucket.append(i)
|
|
47
|
+
id_to_idx[n.id] = (n.cls, idx)
|
|
48
|
+
subtype_vocab.setdefault(n.cls, [])
|
|
49
|
+
if n.subtype and n.subtype not in subtype_vocab[n.cls]:
|
|
50
|
+
subtype_vocab[n.cls].append(n.subtype)
|
|
51
|
+
|
|
52
|
+
for cls, indices in nodes_by_class.items():
|
|
53
|
+
n_subtypes = max(1, len(subtype_vocab[cls]))
|
|
54
|
+
x = torch.zeros((len(indices), n_subtypes), dtype=torch.float32)
|
|
55
|
+
for local_i, global_i in enumerate(indices):
|
|
56
|
+
n = g.nodes[global_i]
|
|
57
|
+
if n.subtype:
|
|
58
|
+
col = subtype_vocab[cls].index(n.subtype)
|
|
59
|
+
x[local_i, col] = 1.0
|
|
60
|
+
data[cls].x = x
|
|
61
|
+
|
|
62
|
+
# Edges: bucket by (src_cls, edge_type, dst_cls).
|
|
63
|
+
edge_buckets: dict[tuple[str, str, str], tuple[list[int], list[int]]] = {}
|
|
64
|
+
for edge in g.edges:
|
|
65
|
+
sc, sidx = id_to_idx[edge.src]
|
|
66
|
+
dc, didx = id_to_idx[edge.dst]
|
|
67
|
+
key = (sc, edge.type, dc)
|
|
68
|
+
if key not in edge_buckets:
|
|
69
|
+
edge_buckets[key] = ([], [])
|
|
70
|
+
edge_buckets[key][0].append(sidx)
|
|
71
|
+
edge_buckets[key][1].append(didx)
|
|
72
|
+
for (sc, etype, dc), (src, dst) in edge_buckets.items():
|
|
73
|
+
edge_index = torch.tensor([src, dst], dtype=torch.long)
|
|
74
|
+
data[(sc, etype, dc)].edge_index = edge_index
|
|
75
|
+
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["to_pyg"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Pyomo export — generates a model skeleton (stub for v0.1).
|
|
2
|
+
|
|
3
|
+
Emits a Python module string that, when executed, defines a Pyomo
|
|
4
|
+
``ConcreteModel`` with sets, parameters, variables, the objective, and
|
|
5
|
+
constraint declarations. The constraint *bodies* are emitted as
|
|
6
|
+
docstring placeholders for v0.1; full body translation lands in v1.0.
|
|
7
|
+
|
|
8
|
+
This is a deliberate v0.1 scope choice — see issue
|
|
9
|
+
``open-question/solver-language-export-scope`` and the v1 acceptance
|
|
10
|
+
criteria.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from lp2graph.core.model import Formulation
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_pyomo_stub(f: Formulation) -> str:
|
|
19
|
+
"""Generate a Pyomo skeleton string from a formulation."""
|
|
20
|
+
lines: list[str] = []
|
|
21
|
+
lines.append('"""Auto-generated Pyomo skeleton from lp2graph.')
|
|
22
|
+
lines.append("")
|
|
23
|
+
lines.append(f"Formulation: {f.id} ({f.name})")
|
|
24
|
+
lines.append(f"Family: {f.family}")
|
|
25
|
+
lines.append('"""')
|
|
26
|
+
lines.append("from pyomo.environ import (")
|
|
27
|
+
lines.append(" ConcreteModel, Set, Param, Var, Constraint, Objective,")
|
|
28
|
+
lines.append(" NonNegativeReals, Reals, Integers, Binary, minimize, maximize,")
|
|
29
|
+
lines.append(")")
|
|
30
|
+
lines.append("")
|
|
31
|
+
lines.append("def build_model() -> ConcreteModel:")
|
|
32
|
+
lines.append(" m = ConcreteModel()")
|
|
33
|
+
for idx in f.indices:
|
|
34
|
+
lines.append(f" m.{idx.name} = Set() # ordered={idx.ordered}, cyclic={idx.cyclic}")
|
|
35
|
+
for p in f.parameters:
|
|
36
|
+
if p.shape:
|
|
37
|
+
sets = ", ".join(f"m.{s}" for s in p.shape)
|
|
38
|
+
lines.append(f" m.{p.name} = Param({sets}, mutable=True)")
|
|
39
|
+
else:
|
|
40
|
+
lines.append(f" m.{p.name} = Param(mutable=True)")
|
|
41
|
+
for v in f.variables:
|
|
42
|
+
domain = {
|
|
43
|
+
"continuous": "Reals",
|
|
44
|
+
"non_negative": "NonNegativeReals",
|
|
45
|
+
"integer": "Integers",
|
|
46
|
+
"binary": "Binary",
|
|
47
|
+
}[v.domain]
|
|
48
|
+
bounds = ""
|
|
49
|
+
if v.lower is not None or v.upper is not None:
|
|
50
|
+
bounds = f", bounds=({v.lower!r}, {v.upper!r})"
|
|
51
|
+
if v.shape:
|
|
52
|
+
sets = ", ".join(f"m.{s}" for s in v.shape)
|
|
53
|
+
lines.append(f" m.{v.name} = Var({sets}, domain={domain}{bounds})")
|
|
54
|
+
else:
|
|
55
|
+
lines.append(f" m.{v.name} = Var(domain={domain}{bounds})")
|
|
56
|
+
if f.objective is not None:
|
|
57
|
+
sense = "minimize" if f.objective.sense == "min" else "maximize"
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append(" def _obj_rule(m):")
|
|
60
|
+
lines.append(f' """{f.objective.description or "Objective"}."""')
|
|
61
|
+
lines.append(" raise NotImplementedError('lp2graph v0.1 emits stubs only')")
|
|
62
|
+
lines.append(f" m.obj = Objective(rule=_obj_rule, sense={sense})")
|
|
63
|
+
for c in f.constraints:
|
|
64
|
+
lines.append("")
|
|
65
|
+
sets_args = ""
|
|
66
|
+
if c.quantifiers:
|
|
67
|
+
sets_args = ", " + ", ".join(f"m.{q.over}" for q in c.quantifiers)
|
|
68
|
+
arg_names = ", ".join(q.index for q in c.quantifiers)
|
|
69
|
+
lines.append(f" def _{c.name}_rule(m, {arg_names}):")
|
|
70
|
+
else:
|
|
71
|
+
lines.append(f" def _{c.name}_rule(m):")
|
|
72
|
+
lines.append(f' """{c.description or c.name}."""')
|
|
73
|
+
lines.append(" raise NotImplementedError('lp2graph v0.1 emits stubs only')")
|
|
74
|
+
lines.append(f" m.{c.name} = Constraint(rule=_{c.name}_rule{sets_args})")
|
|
75
|
+
lines.append("")
|
|
76
|
+
lines.append(" return m")
|
|
77
|
+
lines.append("")
|
|
78
|
+
return "\n".join(lines)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = ["to_pyomo_stub"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Structural metrics over formulations and derived graphs.
|
|
2
|
+
|
|
3
|
+
Every metric is a pure function. Two categories:
|
|
4
|
+
|
|
5
|
+
- **Formulation metrics** consume the canonical model directly.
|
|
6
|
+
Cheaper, exact, no grounding needed.
|
|
7
|
+
- **Graph metrics** consume an internal :class:`~lp2graph.core.graph.Graph`
|
|
8
|
+
(typically the schema or hybrid view). Used when the metric is
|
|
9
|
+
inherently topological (e.g. graph diameter).
|
|
10
|
+
|
|
11
|
+
All metrics return a :class:`MetricResult` — a name, a value, and a
|
|
12
|
+
short human-readable explanation. Determinism is required: snapshot
|
|
13
|
+
tests verify that identical inputs produce identical outputs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from lp2graph.metrics.classification import (
|
|
17
|
+
CONSTRAINT_TYPE_KEYWORDS,
|
|
18
|
+
classify_constraints,
|
|
19
|
+
)
|
|
20
|
+
from lp2graph.metrics.flags import (
|
|
21
|
+
has_aggregation_operator,
|
|
22
|
+
has_big_m,
|
|
23
|
+
has_integer_vars,
|
|
24
|
+
has_modulo_offset,
|
|
25
|
+
has_soft_slack,
|
|
26
|
+
presence_flags,
|
|
27
|
+
)
|
|
28
|
+
from lp2graph.metrics.result import MetricResult
|
|
29
|
+
from lp2graph.metrics.structural import (
|
|
30
|
+
constraint_variable_ratio,
|
|
31
|
+
edge_density,
|
|
32
|
+
graph_diameter,
|
|
33
|
+
minimal_size,
|
|
34
|
+
model_coherence,
|
|
35
|
+
model_completeness,
|
|
36
|
+
node_counts_by_class,
|
|
37
|
+
structural_summary,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"CONSTRAINT_TYPE_KEYWORDS",
|
|
42
|
+
"MetricResult",
|
|
43
|
+
"classify_constraints",
|
|
44
|
+
"constraint_variable_ratio",
|
|
45
|
+
"edge_density",
|
|
46
|
+
"graph_diameter",
|
|
47
|
+
"has_aggregation_operator",
|
|
48
|
+
"has_big_m",
|
|
49
|
+
"has_integer_vars",
|
|
50
|
+
"has_modulo_offset",
|
|
51
|
+
"has_soft_slack",
|
|
52
|
+
"minimal_size",
|
|
53
|
+
"model_coherence",
|
|
54
|
+
"model_completeness",
|
|
55
|
+
"node_counts_by_class",
|
|
56
|
+
"presence_flags",
|
|
57
|
+
"structural_summary",
|
|
58
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Constraint-type classification by keyword matching.
|
|
2
|
+
|
|
3
|
+
Adapted from joernmht/raiLPminerExperimentation
|
|
4
|
+
(railpminer/analysis/constraints.py), MIT License. The keyword tables
|
|
5
|
+
are preserved; the integration consumes the canonical model rather than
|
|
6
|
+
the source repo's flat node lists.
|
|
7
|
+
|
|
8
|
+
Classification is heuristic and intentionally so. It complements the
|
|
9
|
+
declarative ``constraint.kind`` field (which the formulation author
|
|
10
|
+
sets explicitly): ``classify_constraints`` infers the same kinds from
|
|
11
|
+
free-form text in name and description, useful for catalog audits and
|
|
12
|
+
for cross-checking author-supplied kinds.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from lp2graph.core.model import Formulation
|
|
20
|
+
from lp2graph.metrics.result import MetricResult
|
|
21
|
+
|
|
22
|
+
CONSTRAINT_TYPE_KEYWORDS: dict[str, list[str]] = {
|
|
23
|
+
"ordering": [
|
|
24
|
+
r"\bprecedence\b",
|
|
25
|
+
r"\border\b",
|
|
26
|
+
r"\bordering\b",
|
|
27
|
+
r"\bovertaking\b",
|
|
28
|
+
r"\bovertake\b",
|
|
29
|
+
r"\bre-?ordering\b",
|
|
30
|
+
],
|
|
31
|
+
"routing": [
|
|
32
|
+
r"\broute\s+selection\b",
|
|
33
|
+
r"\brouting\b",
|
|
34
|
+
r"\brerouting\b",
|
|
35
|
+
r"\bre-?routing\b",
|
|
36
|
+
],
|
|
37
|
+
"timing": [
|
|
38
|
+
r"\bdepart(ure)?\b",
|
|
39
|
+
r"\bdwell\b",
|
|
40
|
+
r"\bdelay\b",
|
|
41
|
+
r"\bscheduled\b",
|
|
42
|
+
r"\brunning\s+time\b",
|
|
43
|
+
r"\bminimum\s+duration\b",
|
|
44
|
+
r"\bre-?timing\b",
|
|
45
|
+
],
|
|
46
|
+
"cancellation": [
|
|
47
|
+
r"\bcancel(l?ation|l?ed|l?ing)?\b",
|
|
48
|
+
r"\btrain\s+service\s+balance\b",
|
|
49
|
+
r"\bunbalanced\s+timetable\b",
|
|
50
|
+
],
|
|
51
|
+
"headway": [
|
|
52
|
+
r"\bheadway\b",
|
|
53
|
+
r"\bconflict-free\b",
|
|
54
|
+
r"\bincompatible\s+arc\b",
|
|
55
|
+
r"\btrain\s+incompatibility\b",
|
|
56
|
+
],
|
|
57
|
+
"capacity": [
|
|
58
|
+
r"\binfrastructure\s+capacity\b",
|
|
59
|
+
r"\btrack\s+capacity\b",
|
|
60
|
+
r"\bstation\s+capacity\b",
|
|
61
|
+
r"\bblock\s+section\b",
|
|
62
|
+
r"\bsingle-?track\b",
|
|
63
|
+
r"\bno-?store\b",
|
|
64
|
+
],
|
|
65
|
+
"flow_balance": [
|
|
66
|
+
r"\bflow\s+balance\b",
|
|
67
|
+
r"\bflow\s+conservation\b",
|
|
68
|
+
],
|
|
69
|
+
"big_m": [
|
|
70
|
+
r"\blarge\s+constant\b",
|
|
71
|
+
r"\bbig-?M\s+constraint\b",
|
|
72
|
+
r"\bbig-?M\b",
|
|
73
|
+
],
|
|
74
|
+
"passenger_connection": [
|
|
75
|
+
r"\bminimum\s+transfer\s+time\b",
|
|
76
|
+
r"\bpassenger\s+connection\b",
|
|
77
|
+
],
|
|
78
|
+
"rolling_stock_connection": [
|
|
79
|
+
r"\brolling\s+stock\s+connection\b",
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def classify_constraints(f: Formulation) -> MetricResult:
|
|
85
|
+
"""Heuristic classification of every constraint by keyword matches.
|
|
86
|
+
|
|
87
|
+
Returns a dict mapping constraint name to a list of matched type
|
|
88
|
+
tags. Empty list means no keyword matched. The overall ``value`` is
|
|
89
|
+
the per-tag count summary.
|
|
90
|
+
"""
|
|
91
|
+
matrix: dict[str, list[str]] = {}
|
|
92
|
+
type_counts: dict[str, int] = {tag: 0 for tag in CONSTRAINT_TYPE_KEYWORDS}
|
|
93
|
+
|
|
94
|
+
for c in f.constraints:
|
|
95
|
+
haystack = f"{c.name} {c.description}"
|
|
96
|
+
matched: list[str] = []
|
|
97
|
+
for tag, patterns in CONSTRAINT_TYPE_KEYWORDS.items():
|
|
98
|
+
joined = "|".join(patterns)
|
|
99
|
+
if re.search(joined, haystack, re.IGNORECASE):
|
|
100
|
+
matched.append(tag)
|
|
101
|
+
type_counts[tag] += 1
|
|
102
|
+
matrix[c.name] = matched
|
|
103
|
+
|
|
104
|
+
classified = sum(1 for tags in matrix.values() if tags)
|
|
105
|
+
return MetricResult(
|
|
106
|
+
name="classify_constraints",
|
|
107
|
+
value=type_counts,
|
|
108
|
+
explanation=(f"Classified {classified}/{len(matrix)} constraint(s) by keyword."),
|
|
109
|
+
data={"matrix": matrix, "total": len(matrix), "classified": classified},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = ["CONSTRAINT_TYPE_KEYWORDS", "classify_constraints"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Presence flags computed directly from the canonical model.
|
|
2
|
+
|
|
3
|
+
Each flag is a boolean property of the formulation. They are cheap,
|
|
4
|
+
exact, and do not require any view derivation.
|
|
5
|
+
|
|
6
|
+
Adapted from joernmht/raiLPminerExperimentation
|
|
7
|
+
(railpminer/analysis/milp_detection.py), MIT License.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from lp2graph.core.model import Formulation
|
|
13
|
+
from lp2graph.metrics.result import MetricResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def has_big_m(f: Formulation) -> MetricResult:
|
|
17
|
+
"""True if any parameter has kind ``big_m`` or any constraint has kind ``big_m``."""
|
|
18
|
+
by_param = any(p.kind == "big_m" for p in f.parameters)
|
|
19
|
+
by_const = any(c.kind == "big_m" for c in f.constraints)
|
|
20
|
+
return MetricResult(
|
|
21
|
+
name="has_big_m",
|
|
22
|
+
value=by_param or by_const,
|
|
23
|
+
explanation="A big-M parameter or big-M constraint is present.",
|
|
24
|
+
data={"by_parameter": by_param, "by_constraint": by_const},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def has_integer_vars(f: Formulation) -> MetricResult:
|
|
29
|
+
"""True if any variable template is integer or binary."""
|
|
30
|
+
v = any(v.domain in ("integer", "binary") for v in f.variables)
|
|
31
|
+
return MetricResult(
|
|
32
|
+
name="has_integer_vars",
|
|
33
|
+
value=v,
|
|
34
|
+
explanation="At least one integer or binary variable.",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def has_modulo_offset(f: Formulation) -> MetricResult:
|
|
39
|
+
"""True if any term binding declares a modulo wrap.
|
|
40
|
+
|
|
41
|
+
Indicates a PESP-style cyclic formulation. Cyclic indices alone do
|
|
42
|
+
not trigger this flag — the binding has to use the modulo.
|
|
43
|
+
"""
|
|
44
|
+
for c in f.constraints:
|
|
45
|
+
for term in (*c.lhs, *c.rhs):
|
|
46
|
+
if any(b.modulo for b in term.bindings):
|
|
47
|
+
return MetricResult(
|
|
48
|
+
name="has_modulo_offset",
|
|
49
|
+
value=True,
|
|
50
|
+
explanation="At least one term binding wraps modulo an index.",
|
|
51
|
+
)
|
|
52
|
+
if f.objective is not None:
|
|
53
|
+
for term in f.objective.terms:
|
|
54
|
+
if any(b.modulo for b in term.bindings):
|
|
55
|
+
return MetricResult(
|
|
56
|
+
name="has_modulo_offset",
|
|
57
|
+
value=True,
|
|
58
|
+
explanation="Objective term binding wraps modulo an index.",
|
|
59
|
+
)
|
|
60
|
+
return MetricResult(name="has_modulo_offset", value=False, explanation="No modulo bindings.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def has_soft_slack(f: Formulation) -> MetricResult:
|
|
64
|
+
"""True if any variable has role ``slack`` or any term has role ``slack``."""
|
|
65
|
+
by_var = any(v.role == "slack" for v in f.variables)
|
|
66
|
+
by_term = any(any(t.role == "slack" for t in (*c.lhs, *c.rhs)) for c in f.constraints)
|
|
67
|
+
by_obj = bool(f.objective) and any(t.role == "slack" for t in f.objective.terms) # type: ignore[union-attr]
|
|
68
|
+
return MetricResult(
|
|
69
|
+
name="has_soft_slack",
|
|
70
|
+
value=by_var or by_term or by_obj,
|
|
71
|
+
explanation="Slack variable or slack-role term is present.",
|
|
72
|
+
data={"by_variable": by_var, "by_term": by_term, "by_objective": by_obj},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def has_aggregation_operator(f: Formulation) -> MetricResult:
|
|
77
|
+
"""True if any term uses an operator (sum/max/min/abs/indicator/modulo)."""
|
|
78
|
+
for c in f.constraints:
|
|
79
|
+
for term in (*c.lhs, *c.rhs):
|
|
80
|
+
if term.operator != "none":
|
|
81
|
+
return MetricResult(
|
|
82
|
+
name="has_aggregation_operator",
|
|
83
|
+
value=True,
|
|
84
|
+
explanation=f"Operator {term.operator!r} present in {c.name!r}.",
|
|
85
|
+
)
|
|
86
|
+
if f.objective is not None:
|
|
87
|
+
for term in f.objective.terms:
|
|
88
|
+
if term.operator != "none":
|
|
89
|
+
return MetricResult(
|
|
90
|
+
name="has_aggregation_operator",
|
|
91
|
+
value=True,
|
|
92
|
+
explanation=f"Operator {term.operator!r} present in objective.",
|
|
93
|
+
)
|
|
94
|
+
return MetricResult(
|
|
95
|
+
name="has_aggregation_operator",
|
|
96
|
+
value=False,
|
|
97
|
+
explanation="No aggregation operators.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def presence_flags(f: Formulation) -> dict[str, MetricResult]:
|
|
102
|
+
"""Compute every presence flag in a single pass."""
|
|
103
|
+
return {
|
|
104
|
+
m.name: m
|
|
105
|
+
for m in (
|
|
106
|
+
has_big_m(f),
|
|
107
|
+
has_integer_vars(f),
|
|
108
|
+
has_modulo_offset(f),
|
|
109
|
+
has_soft_slack(f),
|
|
110
|
+
has_aggregation_operator(f),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
__all__ = [
|
|
116
|
+
"has_aggregation_operator",
|
|
117
|
+
"has_big_m",
|
|
118
|
+
"has_integer_vars",
|
|
119
|
+
"has_modulo_offset",
|
|
120
|
+
"has_soft_slack",
|
|
121
|
+
"presence_flags",
|
|
122
|
+
]
|