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,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
+ ]