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,101 @@
|
|
|
1
|
+
"""Provenance records for corpus entries (M5).
|
|
2
|
+
|
|
3
|
+
A :class:`ProvenanceRecord` is the bibliographic + categorization sidecar
|
|
4
|
+
for one extracted :class:`~lp2graph.core.model.Formulation`. It captures the
|
|
5
|
+
fields the corpus & provenance manager needs to deduplicate and to select a
|
|
6
|
+
representative per cluster: *where* a formulation came from (``source_id``,
|
|
7
|
+
``venue``, ``year``), *how strong* the citation signal is at the freeze date
|
|
8
|
+
(``citation_count``, ``quality_tier``), and *where it sits* in the priority
|
|
9
|
+
matrix (``domain_shell``, ``activity``, ``priority_cell``).
|
|
10
|
+
|
|
11
|
+
Like the other mining records, this is a dependency-free frozen dataclass:
|
|
12
|
+
it describes provenance *about* a formulation, not the formulation itself.
|
|
13
|
+
Every field is an explicit input — ``citation_count`` is the count *at the
|
|
14
|
+
freeze date*, never fetched at runtime — so a record is a reproducible,
|
|
15
|
+
diffable artifact.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Final, Literal
|
|
22
|
+
|
|
23
|
+
#: Quality tier of a publication venue, used to break selection ties.
|
|
24
|
+
QualityTier = Literal["A_star", "A", "B", "C", "preprint", "unranked"]
|
|
25
|
+
|
|
26
|
+
#: Quality tiers ordered best -> worst. The index of a tier in this tuple is
|
|
27
|
+
#: its rank; lower is better. Used by dedup/selection tie-breaks so that the
|
|
28
|
+
#: ordering is explicit and deterministic.
|
|
29
|
+
QUALITY_TIERS: Final[tuple[QualityTier, ...]] = (
|
|
30
|
+
"A_star",
|
|
31
|
+
"A",
|
|
32
|
+
"B",
|
|
33
|
+
"C",
|
|
34
|
+
"preprint",
|
|
35
|
+
"unranked",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
#: Priority cell of the sampling matrix (``P1`` highest priority .. ``P5``).
|
|
39
|
+
PriorityCell = Literal["P1", "P2", "P3", "P4", "P5"]
|
|
40
|
+
|
|
41
|
+
#: The valid priority cells, highest -> lowest.
|
|
42
|
+
PRIORITY_CELLS: Final[tuple[PriorityCell, ...]] = ("P1", "P2", "P3", "P4", "P5")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def quality_rank(tier: QualityTier) -> int:
|
|
46
|
+
"""Return the rank of ``tier`` (0 = best). Unknown tiers sort last."""
|
|
47
|
+
try:
|
|
48
|
+
return QUALITY_TIERS.index(tier)
|
|
49
|
+
except ValueError:
|
|
50
|
+
return len(QUALITY_TIERS)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class ProvenanceRecord:
|
|
55
|
+
"""Bibliographic + categorization provenance for one corpus entry.
|
|
56
|
+
|
|
57
|
+
``source_id`` is a stable identifier for the source artifact (typically
|
|
58
|
+
the repository or paper id, matching the canonical formulation ``id``).
|
|
59
|
+
``venue`` is the publication / repository venue; ``quality_tier`` ranks
|
|
60
|
+
it (see :data:`QUALITY_TIERS`). ``year`` is the publication year (or
|
|
61
|
+
``None`` when unknown). ``citation_count`` is the citation count *as
|
|
62
|
+
measured at the corpus freeze date* — it is an input, never fetched.
|
|
63
|
+
|
|
64
|
+
``domain_shell`` and ``activity`` place the entry in the domain
|
|
65
|
+
taxonomy; ``priority_cell`` is its cell in the sampling-priority matrix
|
|
66
|
+
(one of :data:`PRIORITY_CELLS`).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
source_id: str
|
|
70
|
+
venue: str
|
|
71
|
+
quality_tier: QualityTier
|
|
72
|
+
year: int | None
|
|
73
|
+
citation_count: int
|
|
74
|
+
domain_shell: str
|
|
75
|
+
activity: str
|
|
76
|
+
priority_cell: PriorityCell
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
if self.priority_cell not in PRIORITY_CELLS:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"priority_cell {self.priority_cell!r} is not one of {PRIORITY_CELLS!r}"
|
|
82
|
+
)
|
|
83
|
+
if self.quality_tier not in QUALITY_TIERS:
|
|
84
|
+
raise ValueError(f"quality_tier {self.quality_tier!r} is not one of {QUALITY_TIERS!r}")
|
|
85
|
+
if self.citation_count < 0:
|
|
86
|
+
raise ValueError("citation_count must be non-negative")
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def quality_rank(self) -> int:
|
|
90
|
+
"""Rank of this record's quality tier (0 = best)."""
|
|
91
|
+
return quality_rank(self.quality_tier)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = [
|
|
95
|
+
"PRIORITY_CELLS",
|
|
96
|
+
"QUALITY_TIERS",
|
|
97
|
+
"PriorityCell",
|
|
98
|
+
"ProvenanceRecord",
|
|
99
|
+
"QualityTier",
|
|
100
|
+
"quality_rank",
|
|
101
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Reproducible representative selection per cluster (M5).
|
|
2
|
+
|
|
3
|
+
Given named clusters (cluster name -> member indices) and the aligned
|
|
4
|
+
provenance records, :func:`select_representatives` picks one member per
|
|
5
|
+
cluster to carry forward into validation. The rule is a pure, documented,
|
|
6
|
+
deterministic function:
|
|
7
|
+
|
|
8
|
+
1. Pick the member with the highest ``citation_count``
|
|
9
|
+
(``reason="highest_citation"``).
|
|
10
|
+
2. If *every* member has ``citation_count == 0`` (no citation signal at all),
|
|
11
|
+
fall back to the next-highest member that *does* have a non-zero count;
|
|
12
|
+
only when none exists is the cluster considered to have no signal
|
|
13
|
+
(``reason="next_highest_fallback"`` once a non-zero member is found).
|
|
14
|
+
3. If the cluster is empty or still has no usable member, fall back to the
|
|
15
|
+
index supplied in ``benchmark_fallback[cluster]``
|
|
16
|
+
(``reason="benchmark_fallback"``).
|
|
17
|
+
|
|
18
|
+
Ties at every step break by best quality tier, then lowest index, so the
|
|
19
|
+
ranking is total and reproducible.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Mapping, Sequence
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Literal
|
|
27
|
+
|
|
28
|
+
from lp2graph.mining.corpusmgr.record import ProvenanceRecord
|
|
29
|
+
|
|
30
|
+
SelectionReason = Literal[
|
|
31
|
+
"highest_citation",
|
|
32
|
+
"next_highest_fallback",
|
|
33
|
+
"benchmark_fallback",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class RepresentativeChoice:
|
|
39
|
+
"""The selection outcome for one cluster.
|
|
40
|
+
|
|
41
|
+
``chosen_index`` is the selected member (or the benchmark-fallback
|
|
42
|
+
index); ``reason`` records which rule fired; ``ranked_candidates`` is the
|
|
43
|
+
full member ranking (best first) under the documented tie-break, for
|
|
44
|
+
auditing and reproducibility.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
chosen_index: int | None
|
|
48
|
+
reason: SelectionReason
|
|
49
|
+
ranked_candidates: tuple[int, ...]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _rank_key(record: ProvenanceRecord, index: int) -> tuple[int, int, int]:
|
|
53
|
+
"""Sort key: higher citation first, then best tier, then lowest index."""
|
|
54
|
+
return (-record.citation_count, record.quality_rank, index)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _rank_members(members: Sequence[int], records: Sequence[ProvenanceRecord]) -> tuple[int, ...]:
|
|
58
|
+
return tuple(sorted(members, key=lambda i: _rank_key(records[i], i)))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def select_representatives(
|
|
62
|
+
clusters: Mapping[str, Sequence[int]],
|
|
63
|
+
records: Sequence[ProvenanceRecord],
|
|
64
|
+
*,
|
|
65
|
+
benchmark_fallback: Mapping[str, int] | None = None,
|
|
66
|
+
) -> dict[str, RepresentativeChoice]:
|
|
67
|
+
"""Pick a reproducible representative for each named cluster.
|
|
68
|
+
|
|
69
|
+
See the module docstring for the exact, deterministic rule. Returns a
|
|
70
|
+
mapping from cluster name to :class:`RepresentativeChoice`.
|
|
71
|
+
"""
|
|
72
|
+
fallback = benchmark_fallback or {}
|
|
73
|
+
out: dict[str, RepresentativeChoice] = {}
|
|
74
|
+
|
|
75
|
+
for name in sorted(clusters):
|
|
76
|
+
members = list(clusters[name])
|
|
77
|
+
ranked = _rank_members(members, records)
|
|
78
|
+
|
|
79
|
+
if not ranked:
|
|
80
|
+
out[name] = RepresentativeChoice(
|
|
81
|
+
chosen_index=fallback.get(name),
|
|
82
|
+
reason="benchmark_fallback",
|
|
83
|
+
ranked_candidates=(),
|
|
84
|
+
)
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
top = ranked[0]
|
|
88
|
+
if records[top].citation_count > 0:
|
|
89
|
+
out[name] = RepresentativeChoice(
|
|
90
|
+
chosen_index=top,
|
|
91
|
+
reason="highest_citation",
|
|
92
|
+
ranked_candidates=ranked,
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
# All members have zero citation signal -> look for any non-zero
|
|
97
|
+
# member (none exist here, but the search is explicit and ordered),
|
|
98
|
+
# else fall back to a benchmark index if provided.
|
|
99
|
+
non_zero = next((i for i in ranked if records[i].citation_count > 0), None)
|
|
100
|
+
if non_zero is not None:
|
|
101
|
+
out[name] = RepresentativeChoice(
|
|
102
|
+
chosen_index=non_zero,
|
|
103
|
+
reason="next_highest_fallback",
|
|
104
|
+
ranked_candidates=ranked,
|
|
105
|
+
)
|
|
106
|
+
elif name in fallback:
|
|
107
|
+
out[name] = RepresentativeChoice(
|
|
108
|
+
chosen_index=fallback[name],
|
|
109
|
+
reason="benchmark_fallback",
|
|
110
|
+
ranked_candidates=ranked,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
# No citation signal and no benchmark fallback: still pick the
|
|
114
|
+
# documented top-of-ranking, but record it as a fallback.
|
|
115
|
+
out[name] = RepresentativeChoice(
|
|
116
|
+
chosen_index=top,
|
|
117
|
+
reason="next_highest_fallback",
|
|
118
|
+
ranked_candidates=ranked,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"RepresentativeChoice",
|
|
126
|
+
"SelectionReason",
|
|
127
|
+
"select_representatives",
|
|
128
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""M2 — lexical homologizer and concept vectorizer.
|
|
2
|
+
|
|
3
|
+
Reduces entity names and descriptions to comparable *concepts*, emits frozen
|
|
4
|
+
TF-IDF concept vectors, and exposes the structural type signature ``τ(s)``
|
|
5
|
+
read from the canonical model. Everything here is deterministic and pinned by
|
|
6
|
+
the versions in :mod:`lp2graph.mining.versions`.
|
|
7
|
+
|
|
8
|
+
Typical use::
|
|
9
|
+
|
|
10
|
+
from lp2graph.mining.homologize import corpus_entities, concept_bag, ConceptVectorizer
|
|
11
|
+
ents = corpus_entities(formulations, "C")
|
|
12
|
+
docs = [concept_bag(e.text) for e in ents]
|
|
13
|
+
vec, vectors = ConceptVectorizer.fit_transform(docs)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from lp2graph.mining.homologize.concept import (
|
|
19
|
+
concept_backend_versions,
|
|
20
|
+
concept_bag,
|
|
21
|
+
concept_of,
|
|
22
|
+
concepts,
|
|
23
|
+
)
|
|
24
|
+
from lp2graph.mining.homologize.entity import (
|
|
25
|
+
Entity,
|
|
26
|
+
EntityLevel,
|
|
27
|
+
corpus_entities,
|
|
28
|
+
entities,
|
|
29
|
+
level_c_entities,
|
|
30
|
+
level_m_entity,
|
|
31
|
+
level_v_entities,
|
|
32
|
+
signature_documents,
|
|
33
|
+
text_dimension,
|
|
34
|
+
)
|
|
35
|
+
from lp2graph.mining.homologize.lemmatize import lemmatize
|
|
36
|
+
from lp2graph.mining.homologize.signature import (
|
|
37
|
+
TypeSignature,
|
|
38
|
+
constraint_signature,
|
|
39
|
+
model_signature,
|
|
40
|
+
objective_signature,
|
|
41
|
+
parameter_signature,
|
|
42
|
+
variable_signature,
|
|
43
|
+
)
|
|
44
|
+
from lp2graph.mining.homologize.thesaurus import PHRASE_TO_CONCEPT
|
|
45
|
+
from lp2graph.mining.homologize.tokenize import STOPLIST, split_compounds, tokenize
|
|
46
|
+
from lp2graph.mining.homologize.vectorize import (
|
|
47
|
+
ConceptCounts,
|
|
48
|
+
ConceptVectorizer,
|
|
49
|
+
Vocabulary,
|
|
50
|
+
build_vocabulary,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"PHRASE_TO_CONCEPT",
|
|
55
|
+
"STOPLIST",
|
|
56
|
+
"ConceptCounts",
|
|
57
|
+
"ConceptVectorizer",
|
|
58
|
+
"Entity",
|
|
59
|
+
"EntityLevel",
|
|
60
|
+
"TypeSignature",
|
|
61
|
+
"Vocabulary",
|
|
62
|
+
"build_vocabulary",
|
|
63
|
+
"concept_backend_versions",
|
|
64
|
+
"concept_bag",
|
|
65
|
+
"concept_of",
|
|
66
|
+
"concepts",
|
|
67
|
+
"constraint_signature",
|
|
68
|
+
"corpus_entities",
|
|
69
|
+
"entities",
|
|
70
|
+
"lemmatize",
|
|
71
|
+
"level_c_entities",
|
|
72
|
+
"level_m_entity",
|
|
73
|
+
"level_v_entities",
|
|
74
|
+
"model_signature",
|
|
75
|
+
"objective_signature",
|
|
76
|
+
"parameter_signature",
|
|
77
|
+
"signature_documents",
|
|
78
|
+
"split_compounds",
|
|
79
|
+
"text_dimension",
|
|
80
|
+
"tokenize",
|
|
81
|
+
"variable_signature",
|
|
82
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Concept mapping ``g: token → concept`` (M2).
|
|
2
|
+
|
|
3
|
+
Surface tokens are folded to concepts in three deterministic stages:
|
|
4
|
+
|
|
5
|
+
1. **Morphology** — :func:`~lp2graph.mining.homologize.lemmatize.lemmatize`
|
|
6
|
+
already collapsed inflections upstream.
|
|
7
|
+
2. **Domain synonymy** — the frozen thesaurus collapses curated synonym
|
|
8
|
+
families (matched greedily as bigrams then unigrams, so ``rolling stock``
|
|
9
|
+
wins over ``rolling`` + ``stock``).
|
|
10
|
+
3. **Optional WordNet** — when the ``nltk`` WordNet corpus is importable, an
|
|
11
|
+
out-of-thesaurus token is folded onto the lemma name of its first synset;
|
|
12
|
+
the pinned database version is recorded. When WordNet is absent the
|
|
13
|
+
concept is the lemma itself (identity) — still fully deterministic.
|
|
14
|
+
|
|
15
|
+
The function ``g`` is :func:`concept_of`; :func:`concept_bag` applies it
|
|
16
|
+
across a piece of text and returns a multiset of concepts.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
|
|
24
|
+
from lp2graph.mining.homologize.lemmatize import lemmatize
|
|
25
|
+
from lp2graph.mining.homologize.thesaurus import PHRASE_TO_CONCEPT
|
|
26
|
+
from lp2graph.mining.homologize.tokenize import tokenize
|
|
27
|
+
from lp2graph.mining.versions import THESAURUS_VERSION, WORDNET_VERSION
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lemmatize_phrase(phrase: str) -> str:
|
|
31
|
+
return " ".join(lemmatize(w) for w in phrase.split())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# The thesaurus, re-keyed into the lemmatized space the tokenizer produces, so
|
|
35
|
+
# phrase lookups line up with lemmatized token n-grams.
|
|
36
|
+
_LEMMA_PHRASE_INDEX: dict[str, str] = {}
|
|
37
|
+
for _phrase, _concept in PHRASE_TO_CONCEPT.items():
|
|
38
|
+
_LEMMA_PHRASE_INDEX.setdefault(_lemmatize_phrase(_phrase), _concept)
|
|
39
|
+
|
|
40
|
+
_MAX_PHRASE_WORDS: int = max((len(p.split()) for p in _LEMMA_PHRASE_INDEX), default=1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@lru_cache(maxsize=4096)
|
|
44
|
+
def _wordnet_concept(lemma: str) -> str | None:
|
|
45
|
+
"""Fold ``lemma`` onto a WordNet synset lemma name, if WordNet is present.
|
|
46
|
+
|
|
47
|
+
Deterministic: always the first synset's first lemma name. Returns
|
|
48
|
+
``None`` when WordNet is unavailable so callers fall back to identity.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
from nltk.corpus import wordnet
|
|
52
|
+
|
|
53
|
+
synsets = wordnet.synsets(lemma)
|
|
54
|
+
except Exception:
|
|
55
|
+
return None
|
|
56
|
+
if not synsets:
|
|
57
|
+
return None
|
|
58
|
+
lemmas = synsets[0].lemmas()
|
|
59
|
+
if not lemmas:
|
|
60
|
+
return None
|
|
61
|
+
name: str = lemmas[0].name().lower()
|
|
62
|
+
return name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def concept_of(token: str, *, use_wordnet: bool = False) -> str:
|
|
66
|
+
"""Map a single surface ``token`` to its concept.
|
|
67
|
+
|
|
68
|
+
Lemmatize, then prefer the thesaurus; optionally consult WordNet; else
|
|
69
|
+
fall back to the lemma itself. Only unigram thesaurus entries are
|
|
70
|
+
consulted here — phrase (bigram) folding lives in :func:`concept_bag`.
|
|
71
|
+
"""
|
|
72
|
+
lemma = lemmatize(token.lower())
|
|
73
|
+
if lemma in _LEMMA_PHRASE_INDEX:
|
|
74
|
+
return _LEMMA_PHRASE_INDEX[lemma]
|
|
75
|
+
if use_wordnet:
|
|
76
|
+
wn = _wordnet_concept(lemma)
|
|
77
|
+
if wn is not None:
|
|
78
|
+
return wn
|
|
79
|
+
return lemma
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def concepts(text: str, *, use_wordnet: bool = False) -> list[str]:
|
|
83
|
+
"""Return the ordered list of concepts for ``text``.
|
|
84
|
+
|
|
85
|
+
Tokenizes, lemmatizes, then performs a greedy longest-first n-gram scan
|
|
86
|
+
against the thesaurus so multi-word concepts are recovered before their
|
|
87
|
+
parts.
|
|
88
|
+
"""
|
|
89
|
+
lemmas = [lemmatize(t) for t in tokenize(text)]
|
|
90
|
+
out: list[str] = []
|
|
91
|
+
i = 0
|
|
92
|
+
n = len(lemmas)
|
|
93
|
+
while i < n:
|
|
94
|
+
matched = False
|
|
95
|
+
upper = min(_MAX_PHRASE_WORDS, n - i)
|
|
96
|
+
for width in range(upper, 1, -1):
|
|
97
|
+
phrase = " ".join(lemmas[i : i + width])
|
|
98
|
+
concept = _LEMMA_PHRASE_INDEX.get(phrase)
|
|
99
|
+
if concept is not None:
|
|
100
|
+
out.append(concept)
|
|
101
|
+
i += width
|
|
102
|
+
matched = True
|
|
103
|
+
break
|
|
104
|
+
if matched:
|
|
105
|
+
continue
|
|
106
|
+
out.append(concept_of(lemmas[i], use_wordnet=use_wordnet))
|
|
107
|
+
i += 1
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def concept_bag(text: str, *, use_wordnet: bool = False) -> Counter[str]:
|
|
112
|
+
"""Return the concept multiset (bag) for ``text``."""
|
|
113
|
+
return Counter(concepts(text, use_wordnet=use_wordnet))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def concept_backend_versions(*, use_wordnet: bool = False) -> dict[str, str | None]:
|
|
117
|
+
"""Report the frozen resource versions behind concept mapping.
|
|
118
|
+
|
|
119
|
+
Stamped into emitted records so a vectorization is reproducible. The
|
|
120
|
+
WordNet version is only reported when the WordNet backend is actually
|
|
121
|
+
engaged and importable.
|
|
122
|
+
"""
|
|
123
|
+
wn_version: str | None = None
|
|
124
|
+
if use_wordnet and _wordnet_concept("test") is not None:
|
|
125
|
+
wn_version = WORDNET_VERSION
|
|
126
|
+
return {"thesaurus": THESAURUS_VERSION, "wordnet": wn_version}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = [
|
|
130
|
+
"concept_backend_versions",
|
|
131
|
+
"concept_bag",
|
|
132
|
+
"concept_of",
|
|
133
|
+
"concepts",
|
|
134
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""The mineable *entity* abstraction (M2, shared with M3/M4).
|
|
2
|
+
|
|
3
|
+
The taxonomy passes operate on *entities*: at Level V the decision variables
|
|
4
|
+
and parameters, at Level C the constraints and the objective, at Level M the
|
|
5
|
+
whole model. Each entity carries both channels the method needs:
|
|
6
|
+
|
|
7
|
+
- ``text`` — the name and description, fed to the lexical homologizer to
|
|
8
|
+
produce a concept bag / TF-IDF vector;
|
|
9
|
+
- ``signature`` — the structural :class:`~lp2graph.mining.homologize.signature.TypeSignature`
|
|
10
|
+
read from the canonical model.
|
|
11
|
+
|
|
12
|
+
This module is the single place that walks a ``Formulation`` and emits
|
|
13
|
+
entities, so clustering and labeling consume one stable, ordered view.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable, Sequence
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
from lp2graph.core.model import Formulation
|
|
23
|
+
from lp2graph.mining.homologize.signature import (
|
|
24
|
+
TypeSignature,
|
|
25
|
+
constraint_signature,
|
|
26
|
+
model_signature,
|
|
27
|
+
objective_signature,
|
|
28
|
+
parameter_signature,
|
|
29
|
+
variable_signature,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
EntityLevel = Literal["V", "C", "M"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Entity:
|
|
37
|
+
"""One mineable unit at some taxonomy level.
|
|
38
|
+
|
|
39
|
+
``id`` is unique within a corpus (``"<formulation>::<dimension>::<name>"``).
|
|
40
|
+
``dimension`` records which kind of entity this is (``variable``,
|
|
41
|
+
``parameter``, ``constraint``, ``objective``, ``model``). ``text`` is the
|
|
42
|
+
lexical channel; ``signature`` is the structural channel; ``tags`` carries
|
|
43
|
+
any extra free text used by the one-dimensional text clusterings.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
formulation_id: str
|
|
48
|
+
level: EntityLevel
|
|
49
|
+
dimension: str
|
|
50
|
+
name: str
|
|
51
|
+
text: str
|
|
52
|
+
signature: TypeSignature
|
|
53
|
+
tags: tuple[str, ...] = field(default_factory=tuple)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _entity_id(formulation_id: str, dimension: str, name: str) -> str:
|
|
57
|
+
return f"{formulation_id}::{dimension}::{name}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def level_v_entities(f: Formulation) -> list[Entity]:
|
|
61
|
+
"""Level-V entities: decision variables then parameters (declaration order)."""
|
|
62
|
+
out: list[Entity] = []
|
|
63
|
+
for v in f.variables:
|
|
64
|
+
out.append(
|
|
65
|
+
Entity(
|
|
66
|
+
id=_entity_id(f.id, "variable", v.name),
|
|
67
|
+
formulation_id=f.id,
|
|
68
|
+
level="V",
|
|
69
|
+
dimension="variable",
|
|
70
|
+
name=v.name,
|
|
71
|
+
text=f"{v.name} {v.description}".strip(),
|
|
72
|
+
signature=variable_signature(v),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
for p in f.parameters:
|
|
76
|
+
out.append(
|
|
77
|
+
Entity(
|
|
78
|
+
id=_entity_id(f.id, "parameter", p.name),
|
|
79
|
+
formulation_id=f.id,
|
|
80
|
+
level="V",
|
|
81
|
+
dimension="parameter",
|
|
82
|
+
name=p.name,
|
|
83
|
+
text=f"{p.name} {p.description}".strip(),
|
|
84
|
+
signature=parameter_signature(p),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def level_c_entities(f: Formulation) -> list[Entity]:
|
|
91
|
+
"""Level-C entities: constraints then the objective (if present)."""
|
|
92
|
+
out: list[Entity] = []
|
|
93
|
+
for c in f.constraints:
|
|
94
|
+
out.append(
|
|
95
|
+
Entity(
|
|
96
|
+
id=_entity_id(f.id, "constraint", c.name),
|
|
97
|
+
formulation_id=f.id,
|
|
98
|
+
level="C",
|
|
99
|
+
dimension="constraint",
|
|
100
|
+
name=c.name,
|
|
101
|
+
text=f"{c.name} {c.description}".strip(),
|
|
102
|
+
signature=constraint_signature(c),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
if f.objective is not None:
|
|
106
|
+
out.append(
|
|
107
|
+
Entity(
|
|
108
|
+
id=_entity_id(f.id, "objective", f.objective.name),
|
|
109
|
+
formulation_id=f.id,
|
|
110
|
+
level="C",
|
|
111
|
+
dimension="objective",
|
|
112
|
+
name=f.objective.name,
|
|
113
|
+
text=f"{f.objective.name} {f.objective.description}".strip(),
|
|
114
|
+
signature=objective_signature(f.objective),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def level_m_entity(f: Formulation) -> Entity:
|
|
121
|
+
"""The single Level-M entity for the whole model.
|
|
122
|
+
|
|
123
|
+
Its ``text`` pools the model name, description, and tags (the lexical
|
|
124
|
+
signal for the one-dimensional *domain* / *solution approach* text
|
|
125
|
+
clusterings); ``tags`` is preserved separately for those dimensions.
|
|
126
|
+
"""
|
|
127
|
+
text = " ".join(part for part in (f.name, f.description, *f.tags) if part).strip()
|
|
128
|
+
return Entity(
|
|
129
|
+
id=_entity_id(f.id, "model", f.id),
|
|
130
|
+
formulation_id=f.id,
|
|
131
|
+
level="M",
|
|
132
|
+
dimension="model",
|
|
133
|
+
name=f.name,
|
|
134
|
+
text=text,
|
|
135
|
+
signature=model_signature(f),
|
|
136
|
+
tags=tuple(f.tags),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def entities(f: Formulation, level: EntityLevel) -> list[Entity]:
|
|
141
|
+
"""Return the entities of ``f`` at ``level`` (``"V"``, ``"C"``, or ``"M"``)."""
|
|
142
|
+
if level == "V":
|
|
143
|
+
return level_v_entities(f)
|
|
144
|
+
if level == "C":
|
|
145
|
+
return level_c_entities(f)
|
|
146
|
+
return [level_m_entity(f)]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def corpus_entities(formulations: Iterable[Formulation], level: EntityLevel) -> list[Entity]:
|
|
150
|
+
"""Flatten the entities of many formulations at one ``level``.
|
|
151
|
+
|
|
152
|
+
Order is deterministic: formulations in the given order, entities in
|
|
153
|
+
declaration order within each.
|
|
154
|
+
"""
|
|
155
|
+
out: list[Entity] = []
|
|
156
|
+
for f in formulations:
|
|
157
|
+
out.extend(entities(f, level))
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def text_dimension(entity: Entity, dimension: Literal["domain", "solution_approach"]) -> str:
|
|
162
|
+
"""Return the text for a one-dimensional model-level text clustering.
|
|
163
|
+
|
|
164
|
+
Both dimensions are derived from the Level-M entity's available free text;
|
|
165
|
+
``domain`` favors the tags + description, ``solution_approach`` favors the
|
|
166
|
+
description prose. The canonical model does not separate these fields, so
|
|
167
|
+
the split is a documented heuristic over the same source text.
|
|
168
|
+
"""
|
|
169
|
+
if entity.level != "M": # pragma: no cover - guarded by callers
|
|
170
|
+
raise ValueError("text dimensions are only defined for Level-M entities")
|
|
171
|
+
if dimension == "domain":
|
|
172
|
+
return " ".join((*entity.tags, entity.text)).strip()
|
|
173
|
+
return entity.text
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def signature_documents(items: Sequence[Entity]) -> list[dict[str, int]]:
|
|
177
|
+
"""Turn each entity's signature into a categorical concept-count document.
|
|
178
|
+
|
|
179
|
+
Lets the structural channel be vectorized by the same TF-IDF machinery as
|
|
180
|
+
the lexical channel: each signature facet becomes a namespaced concept
|
|
181
|
+
token (``role:auxiliary``, ``kind:binary``, ``domain:timing``,
|
|
182
|
+
``cmp:le``, ``quant:i:I:ne_other``, ``ref:variable``, ...).
|
|
183
|
+
"""
|
|
184
|
+
docs: list[dict[str, int]] = []
|
|
185
|
+
for e in items:
|
|
186
|
+
sig = e.signature
|
|
187
|
+
tokens: list[str] = []
|
|
188
|
+
if sig.domain:
|
|
189
|
+
tokens.append(f"domain:{sig.domain}")
|
|
190
|
+
tokens.append(f"role:{sig.role}")
|
|
191
|
+
tokens.append(f"kind:{sig.kind}")
|
|
192
|
+
tokens.append(f"arity:{sig.arity}")
|
|
193
|
+
tokens.extend(f"over:{s}" for s in sig.shape)
|
|
194
|
+
# Constraint τ extras (paper Eq. homologization): comparator,
|
|
195
|
+
# quantifier/restriction pattern, and the referent-kind multiset.
|
|
196
|
+
if sig.comparator:
|
|
197
|
+
tokens.append(f"cmp:{sig.comparator}")
|
|
198
|
+
tokens.extend(f"quant:{q}" for q in sig.quantifiers)
|
|
199
|
+
tokens.extend(f"ref:{r}" for r in sig.referents)
|
|
200
|
+
doc: dict[str, int] = {}
|
|
201
|
+
for tok in tokens:
|
|
202
|
+
doc[tok] = doc.get(tok, 0) + 1
|
|
203
|
+
docs.append(doc)
|
|
204
|
+
return docs
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
__all__ = [
|
|
208
|
+
"Entity",
|
|
209
|
+
"EntityLevel",
|
|
210
|
+
"corpus_entities",
|
|
211
|
+
"entities",
|
|
212
|
+
"level_c_entities",
|
|
213
|
+
"level_m_entity",
|
|
214
|
+
"level_v_entities",
|
|
215
|
+
"signature_documents",
|
|
216
|
+
"text_dimension",
|
|
217
|
+
]
|