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.
Files changed (80) hide show
  1. lp2graph/__init__.py +54 -0
  2. lp2graph/cli.py +238 -0
  3. lp2graph/codec/__init__.py +41 -0
  4. lp2graph/codec/latex.py +884 -0
  5. lp2graph/codec/normalize.py +82 -0
  6. lp2graph/core/__init__.py +35 -0
  7. lp2graph/core/graph.py +183 -0
  8. lp2graph/core/loader.py +63 -0
  9. lp2graph/core/model.py +437 -0
  10. lp2graph/core/validate.py +237 -0
  11. lp2graph/export/__init__.py +13 -0
  12. lp2graph/export/dgl.py +51 -0
  13. lp2graph/export/latex.py +126 -0
  14. lp2graph/export/networkx_adapter.py +50 -0
  15. lp2graph/export/pyg.py +79 -0
  16. lp2graph/export/pyomo_stub.py +81 -0
  17. lp2graph/metrics/__init__.py +58 -0
  18. lp2graph/metrics/classification.py +113 -0
  19. lp2graph/metrics/flags.py +122 -0
  20. lp2graph/metrics/result.py +26 -0
  21. lp2graph/metrics/structural.py +236 -0
  22. lp2graph/mining/__init__.py +47 -0
  23. lp2graph/mining/cluster/__init__.py +65 -0
  24. lp2graph/mining/cluster/agglomerative.py +82 -0
  25. lp2graph/mining/cluster/distance.py +65 -0
  26. lp2graph/mining/cluster/operator.py +218 -0
  27. lp2graph/mining/cluster/silhouette.py +88 -0
  28. lp2graph/mining/cluster/stability.py +178 -0
  29. lp2graph/mining/cluster/taxonomy.py +268 -0
  30. lp2graph/mining/corpusmgr/__init__.py +70 -0
  31. lp2graph/mining/corpusmgr/dedup.py +183 -0
  32. lp2graph/mining/corpusmgr/manager.py +79 -0
  33. lp2graph/mining/corpusmgr/manifest.py +82 -0
  34. lp2graph/mining/corpusmgr/record.py +101 -0
  35. lp2graph/mining/corpusmgr/select.py +128 -0
  36. lp2graph/mining/homologize/__init__.py +82 -0
  37. lp2graph/mining/homologize/concept.py +134 -0
  38. lp2graph/mining/homologize/entity.py +217 -0
  39. lp2graph/mining/homologize/lemmatize.py +80 -0
  40. lp2graph/mining/homologize/signature.py +166 -0
  41. lp2graph/mining/homologize/thesaurus.py +70 -0
  42. lp2graph/mining/homologize/tokenize.py +255 -0
  43. lp2graph/mining/homologize/vectorize.py +141 -0
  44. lp2graph/mining/ingest/__init__.py +59 -0
  45. lp2graph/mining/ingest/code_importers.py +104 -0
  46. lp2graph/mining/ingest/dispatch.py +148 -0
  47. lp2graph/mining/ingest/latex_normalizer.py +243 -0
  48. lp2graph/mining/ingest/pyomo_importer.py +297 -0
  49. lp2graph/mining/ingest/result.py +124 -0
  50. lp2graph/mining/isomorphism/__init__.py +26 -0
  51. lp2graph/mining/isomorphism/report.py +178 -0
  52. lp2graph/mining/label/__init__.py +70 -0
  53. lp2graph/mining/label/classifier.py +161 -0
  54. lp2graph/mining/label/features.py +35 -0
  55. lp2graph/mining/label/guardrails.py +176 -0
  56. lp2graph/mining/label/loop.py +314 -0
  57. lp2graph/mining/label/rules.py +92 -0
  58. lp2graph/mining/label/store.py +164 -0
  59. lp2graph/mining/label/vocab.py +64 -0
  60. lp2graph/mining/provenance.py +90 -0
  61. lp2graph/mining/versions.py +51 -0
  62. lp2graph/nl/__init__.py +15 -0
  63. lp2graph/nl/describe.py +301 -0
  64. lp2graph/render/__init__.py +11 -0
  65. lp2graph/render/palette.py +80 -0
  66. lp2graph/render/svg.py +220 -0
  67. lp2graph/solve/__init__.py +50 -0
  68. lp2graph/solve/grounder.py +405 -0
  69. lp2graph/solve/instance.py +76 -0
  70. lp2graph/transform/__init__.py +30 -0
  71. lp2graph/transform/bigm.py +173 -0
  72. lp2graph/views/__init__.py +17 -0
  73. lp2graph/views/ground.py +477 -0
  74. lp2graph/views/hybrid.py +202 -0
  75. lp2graph/views/schema.py +208 -0
  76. lp2graph-0.3.0.dist-info/METADATA +206 -0
  77. lp2graph-0.3.0.dist-info/RECORD +80 -0
  78. lp2graph-0.3.0.dist-info/WHEEL +4 -0
  79. lp2graph-0.3.0.dist-info/entry_points.txt +2 -0
  80. 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"]
@@ -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"]