c2score 0.1.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.
- c2score/__init__.py +25 -0
- c2score/_instance_json.py +165 -0
- c2score/cardinality.py +94 -0
- c2score/conciseness.py +191 -0
- c2score/coverage.py +222 -0
- c2score/datatypes.py +108 -0
- c2score/errors.py +17 -0
- c2score/evaluate.py +264 -0
- c2score/flatten.py +185 -0
- c2score/loaders/__init__.py +5 -0
- c2score/loaders/neo4j.py +270 -0
- c2score/model.py +379 -0
- c2score/resources/schema.json +74 -0
- c2score/schema_json.py +392 -0
- c2score/similarity.py +194 -0
- c2score-0.1.0.dist-info/METADATA +314 -0
- c2score-0.1.0.dist-info/RECORD +19 -0
- c2score-0.1.0.dist-info/WHEEL +4 -0
- c2score-0.1.0.dist-info/licenses/LICENSE +21 -0
c2score/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""C2-score: quantitative quality evaluation of property graph schemas."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from .errors import C2ScoreError, InstanceLoadError, ParameterError, SchemaError
|
|
6
|
+
from .evaluate import EvaluationResult, evaluate
|
|
7
|
+
from .loaders import load_neo4j
|
|
8
|
+
from .model import Instance, Schema
|
|
9
|
+
from .schema_json import load_schema_json
|
|
10
|
+
|
|
11
|
+
__version__ = version("c2score")
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"load_neo4j",
|
|
15
|
+
"load_schema_json",
|
|
16
|
+
"evaluate",
|
|
17
|
+
"Instance",
|
|
18
|
+
"Schema",
|
|
19
|
+
"EvaluationResult",
|
|
20
|
+
"C2ScoreError",
|
|
21
|
+
"SchemaError",
|
|
22
|
+
"InstanceLoadError",
|
|
23
|
+
"ParameterError",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Saving and restoring Abs(G) as JSON (``Instance.to_json`` / ``Instance.from_json``).
|
|
2
|
+
|
|
3
|
+
The file holds the summary, not the graph::
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"format": "c2score-instance",
|
|
7
|
+
"version": 1,
|
|
8
|
+
"node_types": [{"labels": [...], "properties": {key: {"type": ..., "required": ...}}}],
|
|
9
|
+
"edge_types": [{"labels": [...], "source_labels": [...], "target_labels": [...],
|
|
10
|
+
"cardinality": {"out": "0..1", "in": "1..*"}, "properties": {...}}]
|
|
11
|
+
}
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from typing import Any, Union
|
|
20
|
+
|
|
21
|
+
from .cardinality import Cardinality, format_interval, parse_interval
|
|
22
|
+
from .datatypes import DataType
|
|
23
|
+
from .errors import InstanceLoadError
|
|
24
|
+
from .model import Instance, InstanceEdgeType, InstanceNodeType, PropertySpec
|
|
25
|
+
|
|
26
|
+
FORMAT = "c2score-instance"
|
|
27
|
+
VERSION = 1
|
|
28
|
+
|
|
29
|
+
_TOP_LEVEL_KEYS = ("format", "version", "node_types", "edge_types")
|
|
30
|
+
_NODE_KEYS = ("labels", "properties")
|
|
31
|
+
_EDGE_KEYS = ("labels", "source_labels", "target_labels", "cardinality", "properties")
|
|
32
|
+
|
|
33
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _dump_properties(properties: Mapping[str, PropertySpec]) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
key: {"type": spec.type.value, "required": spec.required}
|
|
39
|
+
for key, spec in sorted(properties.items())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def instance_to_dict(instance: Instance) -> dict[str, Any]:
|
|
44
|
+
"""Return the JSON-serializable form of ``instance``."""
|
|
45
|
+
return {
|
|
46
|
+
"format": FORMAT,
|
|
47
|
+
"version": VERSION,
|
|
48
|
+
"node_types": [
|
|
49
|
+
{"labels": sorted(node.labels), "properties": _dump_properties(node.properties)}
|
|
50
|
+
for node in instance.node_types
|
|
51
|
+
],
|
|
52
|
+
"edge_types": [
|
|
53
|
+
{
|
|
54
|
+
"labels": sorted(edge.labels),
|
|
55
|
+
"source_labels": sorted(edge.source_labels),
|
|
56
|
+
"target_labels": sorted(edge.target_labels),
|
|
57
|
+
"cardinality": {
|
|
58
|
+
"out": format_interval(edge.cardinality.out),
|
|
59
|
+
"in": format_interval(edge.cardinality.in_),
|
|
60
|
+
},
|
|
61
|
+
"properties": _dump_properties(edge.properties),
|
|
62
|
+
}
|
|
63
|
+
for edge in instance.edge_types
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save(instance: Instance, path: PathLike) -> None:
|
|
69
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
70
|
+
json.dump(instance_to_dict(instance), f, ensure_ascii=False, indent=2)
|
|
71
|
+
f.write("\n")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_keys(value: Mapping[str, Any], keys: tuple[str, ...], where: str) -> None:
|
|
75
|
+
"""Reject missing and unknown keys, so that a misspelled key is not silently ignored."""
|
|
76
|
+
for key in keys:
|
|
77
|
+
if key not in value:
|
|
78
|
+
raise ValueError(f"{where}missing key {key!r}")
|
|
79
|
+
unknown = [key for key in value if key not in keys]
|
|
80
|
+
if unknown:
|
|
81
|
+
listed = ", ".join(repr(key) for key in unknown)
|
|
82
|
+
raise ValueError(f"{where}unknown key{'s' if len(unknown) > 1 else ''} {listed}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _labels(value: Any, where: str) -> frozenset[str]:
|
|
86
|
+
if not isinstance(value, list) or not all(isinstance(label, str) for label in value):
|
|
87
|
+
raise ValueError(f"{where}: expected a list of strings, got {value!r}")
|
|
88
|
+
return frozenset(value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load_properties(value: Any, where: str) -> dict[str, PropertySpec]:
|
|
92
|
+
if not isinstance(value, dict):
|
|
93
|
+
raise ValueError(f"{where}: expected an object, got {value!r}")
|
|
94
|
+
properties = {}
|
|
95
|
+
for key, spec in value.items():
|
|
96
|
+
if not isinstance(spec, dict) or set(spec) != {"type", "required"}:
|
|
97
|
+
raise ValueError(f"{where}.{key}: expected {{\"type\", \"required\"}}, got {spec!r}")
|
|
98
|
+
if not isinstance(spec["required"], bool):
|
|
99
|
+
raise ValueError(f"{where}.{key}.required: expected true or false, got {spec['required']!r}")
|
|
100
|
+
try:
|
|
101
|
+
data_type = DataType(spec["type"])
|
|
102
|
+
except ValueError:
|
|
103
|
+
raise ValueError(f"{where}.{key}.type: unknown type {spec['type']!r}") from None
|
|
104
|
+
properties[key] = PropertySpec(data_type, spec["required"])
|
|
105
|
+
return properties
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _list(data: Mapping[str, Any], key: str) -> list[Any]:
|
|
109
|
+
value = data.get(key)
|
|
110
|
+
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
111
|
+
raise ValueError(f"{key}: expected a list of objects")
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def instance_from_dict(data: Any) -> Instance:
|
|
116
|
+
"""Rebuild an :class:`Instance` from :func:`instance_to_dict` output.
|
|
117
|
+
|
|
118
|
+
Raises ``ValueError`` on malformed input, including missing and unknown keys.
|
|
119
|
+
"""
|
|
120
|
+
if not isinstance(data, dict) or data.get("format") != FORMAT:
|
|
121
|
+
raise ValueError(f"not a c2score instance file (expected \"format\": {FORMAT!r})")
|
|
122
|
+
version = data.get("version")
|
|
123
|
+
# The version is an integer; True and 1.0 compare equal to 1 but are not accepted.
|
|
124
|
+
if type(version) is not int or version != VERSION:
|
|
125
|
+
raise ValueError(f"unsupported version {version!r}; this c2score reads version {VERSION}")
|
|
126
|
+
_check_keys(data, _TOP_LEVEL_KEYS, "")
|
|
127
|
+
node_types = []
|
|
128
|
+
for i, node in enumerate(_list(data, "node_types")):
|
|
129
|
+
where = f"node_types[{i}]"
|
|
130
|
+
_check_keys(node, _NODE_KEYS, f"{where}: ")
|
|
131
|
+
node_types.append(
|
|
132
|
+
InstanceNodeType(
|
|
133
|
+
_labels(node["labels"], f"{where}.labels"),
|
|
134
|
+
_load_properties(node["properties"], f"{where}.properties"),
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
edge_types = []
|
|
138
|
+
for i, edge in enumerate(_list(data, "edge_types")):
|
|
139
|
+
where = f"edge_types[{i}]"
|
|
140
|
+
_check_keys(edge, _EDGE_KEYS, f"{where}: ")
|
|
141
|
+
cardinality = edge["cardinality"]
|
|
142
|
+
if not isinstance(cardinality, dict) or set(cardinality) != {"out", "in"}:
|
|
143
|
+
raise ValueError(f"{where}.cardinality: expected {{\"out\", \"in\"}}, got {cardinality!r}")
|
|
144
|
+
edge_types.append(
|
|
145
|
+
InstanceEdgeType(
|
|
146
|
+
_labels(edge["labels"], f"{where}.labels"),
|
|
147
|
+
_labels(edge["source_labels"], f"{where}.source_labels"),
|
|
148
|
+
_labels(edge["target_labels"], f"{where}.target_labels"),
|
|
149
|
+
Cardinality(parse_interval(cardinality["out"]), parse_interval(cardinality["in"])),
|
|
150
|
+
_load_properties(edge["properties"], f"{where}.properties"),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return Instance(tuple(node_types), tuple(edge_types))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def load(path: PathLike) -> Instance:
|
|
157
|
+
origin = os.fspath(path)
|
|
158
|
+
with open(path, "rb") as f:
|
|
159
|
+
raw = f.read()
|
|
160
|
+
try:
|
|
161
|
+
return instance_from_dict(json.loads(raw.decode("utf-8")))
|
|
162
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
163
|
+
raise InstanceLoadError(f"invalid instance file ({origin}): not valid UTF-8 JSON: {e}") from e
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
raise InstanceLoadError(f"invalid instance file ({origin}): {e}") from e
|
c2score/cardinality.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Cardinality intervals and their similarity (D_card).
|
|
2
|
+
|
|
3
|
+
An interval is a pair ``(lower, upper)`` with ``lower`` in ``{0, 1}`` and
|
|
4
|
+
``upper`` in ``{1, "*"}``. Only the four intervals ``0..1``, ``0..*``,
|
|
5
|
+
``1..1`` and ``1..*`` exist; observed degree bounds are always rounded to one
|
|
6
|
+
of them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import NamedTuple, Union
|
|
12
|
+
|
|
13
|
+
STAR = "*"
|
|
14
|
+
|
|
15
|
+
Bound = Union[int, str]
|
|
16
|
+
Interval = tuple[int, Bound]
|
|
17
|
+
|
|
18
|
+
ZERO_ONE: Interval = (0, 1)
|
|
19
|
+
ZERO_MANY: Interval = (0, STAR)
|
|
20
|
+
ONE_ONE: Interval = (1, 1)
|
|
21
|
+
ONE_MANY: Interval = (1, STAR)
|
|
22
|
+
|
|
23
|
+
_BY_NAME: dict[str, Interval] = {
|
|
24
|
+
"0..1": ZERO_ONE,
|
|
25
|
+
"0..*": ZERO_MANY,
|
|
26
|
+
"1..1": ONE_ONE,
|
|
27
|
+
"1..*": ONE_MANY,
|
|
28
|
+
}
|
|
29
|
+
_NAME_OF: dict[Interval, str] = {interval: name for name, interval in _BY_NAME.items()}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Cardinality(NamedTuple):
|
|
33
|
+
"""The out-degree and in-degree intervals of an edge type."""
|
|
34
|
+
|
|
35
|
+
out: Interval
|
|
36
|
+
in_: Interval
|
|
37
|
+
|
|
38
|
+
def __str__(self) -> str:
|
|
39
|
+
return f"out {format_interval(self.out)} / in {format_interval(self.in_)}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
#: The cardinality assumed for an edge type that has none.
|
|
43
|
+
DEFAULT_CARDINALITY = Cardinality(ZERO_MANY, ZERO_MANY)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_interval(text: str) -> Interval:
|
|
47
|
+
"""Parse one of ``"0..1"``, ``"0..*"``, ``"1..1"``, ``"1..*"``."""
|
|
48
|
+
try:
|
|
49
|
+
return _BY_NAME[text]
|
|
50
|
+
except (KeyError, TypeError):
|
|
51
|
+
allowed = ", ".join(repr(name) for name in _BY_NAME)
|
|
52
|
+
raise ValueError(f"invalid interval {text!r}; expected one of {allowed}") from None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_interval(interval: Interval) -> str:
|
|
56
|
+
"""Return the text form of one of the four intervals."""
|
|
57
|
+
try:
|
|
58
|
+
return _NAME_OF[tuple(interval)]
|
|
59
|
+
except (KeyError, TypeError):
|
|
60
|
+
raise ValueError(f"not one of the four intervals: {interval!r}") from None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def round_observed(min_degree: int, max_degree: int) -> Interval:
|
|
64
|
+
"""Round observed degree bounds ``[min, max]`` to one of the four intervals.
|
|
65
|
+
|
|
66
|
+
The lower bound becomes 0 if ``min`` is 0 and 1 otherwise; the upper bound
|
|
67
|
+
becomes 1 if ``max`` is at most 1 and ``"*"`` otherwise.
|
|
68
|
+
"""
|
|
69
|
+
if min_degree < 0 or max_degree < min_degree or max_degree < 1:
|
|
70
|
+
raise ValueError(f"invalid observed degree bounds [{min_degree}, {max_degree}]")
|
|
71
|
+
lower = 0 if min_degree == 0 else 1
|
|
72
|
+
upper: Bound = 1 if max_degree <= 1 else STAR
|
|
73
|
+
return (lower, upper)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def bd(x: Bound, y: Bound) -> float:
|
|
77
|
+
"""Similarity of two bounds (Eq. 33)."""
|
|
78
|
+
x_star = x == STAR
|
|
79
|
+
y_star = y == STAR
|
|
80
|
+
if x_star and y_star:
|
|
81
|
+
return 1.0
|
|
82
|
+
if x_star or y_star:
|
|
83
|
+
return 0.0
|
|
84
|
+
return (min(x, y) + 1) / (max(x, y) + 1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def d_int(a: Interval, b: Interval) -> float:
|
|
88
|
+
"""Similarity of two intervals (Eq. 32)."""
|
|
89
|
+
return (bd(a[0], b[0]) + bd(a[1], b[1])) / 2
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def d_card(c: Cardinality, c2: Cardinality) -> float:
|
|
93
|
+
"""Similarity of two cardinalities (Eq. 31)."""
|
|
94
|
+
return (d_int(c.out, c2.out) + d_int(c.in_, c2.in_)) / 2
|
c2score/conciseness.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Conciseness: which written definitions are redundant (Section 4.7).
|
|
2
|
+
|
|
3
|
+
Each definition is removed in turn, together with everything that depends on
|
|
4
|
+
it, and the coverage of the reduced schema is compared with the original.
|
|
5
|
+
|
|
6
|
+
When no other definition inherits from the removed one (and, for a node
|
|
7
|
+
definition, no edge definition that goes with it), the remaining definitions
|
|
8
|
+
flatten exactly as before: S+ of the reduced schema is S+ minus the elements
|
|
9
|
+
that the removal takes away. The coverage is then read off the original
|
|
10
|
+
similarities. Otherwise the reduced schema is flattened again.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Collection
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
from typing import Optional, TypeVar
|
|
18
|
+
|
|
19
|
+
from .coverage import CoverageTables
|
|
20
|
+
from .flatten import FlatSchema, flatten, inherited_endpoint
|
|
21
|
+
from .model import EdgeTypeDef, NodeTypeDef, Schema
|
|
22
|
+
from .similarity import Similarity
|
|
23
|
+
|
|
24
|
+
_D = TypeVar("_D", NodeTypeDef, EdgeTypeDef)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Removal:
|
|
29
|
+
"""The effect of removing one definition."""
|
|
30
|
+
|
|
31
|
+
type_id: str
|
|
32
|
+
delta_v: float
|
|
33
|
+
delta_e: float
|
|
34
|
+
redundant: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ConcisenessResult:
|
|
39
|
+
con_v: float
|
|
40
|
+
con_e: float
|
|
41
|
+
theta_v: float
|
|
42
|
+
theta_e: float
|
|
43
|
+
node_removals: tuple[Removal, ...]
|
|
44
|
+
edge_removals: tuple[Removal, ...]
|
|
45
|
+
#: The definitions whose removal needed a new flattening (the others were incremental).
|
|
46
|
+
reflattened: tuple[str, ...] = ()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def reduce_schema(schema: Schema, type_id: str) -> Schema:
|
|
50
|
+
"""S - d: remove a definition and, repeatedly, what no longer holds without it.
|
|
51
|
+
|
|
52
|
+
Removed definitions are dropped from the remaining ``supertypes``. An edge
|
|
53
|
+
definition whose endpoint (possibly inherited) is removed or no longer
|
|
54
|
+
determined is removed as well.
|
|
55
|
+
"""
|
|
56
|
+
removed = {type_id}
|
|
57
|
+
|
|
58
|
+
def kept(definition: _D) -> _D:
|
|
59
|
+
if not any(parent in removed for parent in definition.supertypes):
|
|
60
|
+
return definition
|
|
61
|
+
return replace(definition, supertypes=tuple(p for p in definition.supertypes if p not in removed))
|
|
62
|
+
|
|
63
|
+
while True:
|
|
64
|
+
reduced = Schema(
|
|
65
|
+
node_types=tuple(kept(n) for n in schema.node_types if n.id not in removed),
|
|
66
|
+
edge_types=tuple(kept(e) for e in schema.edge_types if e.id not in removed),
|
|
67
|
+
)
|
|
68
|
+
broken = set()
|
|
69
|
+
for edge in reduced.edge_types:
|
|
70
|
+
for end in ("source", "target"):
|
|
71
|
+
endpoint = inherited_endpoint(reduced, edge, end)
|
|
72
|
+
if endpoint is None or endpoint in removed:
|
|
73
|
+
broken.add(edge.id)
|
|
74
|
+
if not broken:
|
|
75
|
+
return reduced
|
|
76
|
+
removed |= broken
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ratio(redundant: int, total: int) -> float:
|
|
80
|
+
"""1 - redundant / total, or 1 if there is nothing to remove."""
|
|
81
|
+
return 1.0 if total == 0 else 1 - redundant / total
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _Shortcuts:
|
|
85
|
+
"""What a removal takes away from S+, for the removals that leave the rest of S+ unchanged."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, schema: Schema, flat: FlatSchema) -> None:
|
|
88
|
+
children: dict[str, list[str]] = {}
|
|
89
|
+
for definition in (*schema.node_types, *schema.edge_types):
|
|
90
|
+
for parent in definition.supertypes:
|
|
91
|
+
children.setdefault(parent, []).append(definition.id)
|
|
92
|
+
attached: dict[str, set[str]] = {}
|
|
93
|
+
for edge in schema.edge_types:
|
|
94
|
+
for end in ("source", "target"):
|
|
95
|
+
endpoint = inherited_endpoint(schema, edge, end)
|
|
96
|
+
if endpoint is not None:
|
|
97
|
+
attached.setdefault(endpoint, set()).add(edge.id)
|
|
98
|
+
self._children = children
|
|
99
|
+
self._attached = attached
|
|
100
|
+
self._node_column = {s.id: j for j, s in enumerate(flat.node_types)}
|
|
101
|
+
self._touching: dict[str, set[int]] = {}
|
|
102
|
+
self._only_from: dict[str, set[int]] = {}
|
|
103
|
+
for j, e in enumerate(flat.edge_types):
|
|
104
|
+
self._touching.setdefault(e.source.id, set()).add(j)
|
|
105
|
+
self._touching.setdefault(e.target.id, set()).add(j)
|
|
106
|
+
if len(e.origins) == 1:
|
|
107
|
+
self._only_from.setdefault(next(iter(e.origins)), set()).add(j)
|
|
108
|
+
|
|
109
|
+
def node(self, type_id: str) -> Optional[tuple[int, Collection[int]]]:
|
|
110
|
+
"""For a node definition: its column in V(S+) and the columns of E(S+) that go with it.
|
|
111
|
+
|
|
112
|
+
``None`` if the removal changes other elements of S+. That happens if
|
|
113
|
+
some definition inherits from it, or from an edge definition removed
|
|
114
|
+
with it (one whose endpoint, possibly inherited, is this node).
|
|
115
|
+
Otherwise the removed edge definitions only produce elements whose
|
|
116
|
+
source or target is this node (it has no descendants), and every
|
|
117
|
+
other element survives with the same content.
|
|
118
|
+
"""
|
|
119
|
+
if self._children.get(type_id):
|
|
120
|
+
return None
|
|
121
|
+
removed_edges = self._attached.get(type_id, set())
|
|
122
|
+
for edge_id in removed_edges:
|
|
123
|
+
if any(child not in removed_edges for child in self._children.get(edge_id, ())):
|
|
124
|
+
return None
|
|
125
|
+
return self._node_column[type_id], self._touching.get(type_id, set())
|
|
126
|
+
|
|
127
|
+
def edge(self, type_id: str) -> Optional[Collection[int]]:
|
|
128
|
+
"""For an edge definition: the columns of E(S+) produced by it alone.
|
|
129
|
+
|
|
130
|
+
``None`` if some definition inherits from it.
|
|
131
|
+
"""
|
|
132
|
+
if self._children.get(type_id):
|
|
133
|
+
return None
|
|
134
|
+
return self._only_from.get(type_id, set())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def conciseness(
|
|
138
|
+
schema: Schema,
|
|
139
|
+
flat: FlatSchema,
|
|
140
|
+
base_cov_v: float,
|
|
141
|
+
base_cov_e: float,
|
|
142
|
+
similarity: Similarity,
|
|
143
|
+
redundancy_ratio: float,
|
|
144
|
+
*,
|
|
145
|
+
tables: Optional[CoverageTables] = None,
|
|
146
|
+
) -> ConcisenessResult:
|
|
147
|
+
"""Con_V and Con_E. ``flat``, the base coverages and ``tables`` belong to the original schema."""
|
|
148
|
+
n_v, n_e = len(flat.node_types), len(flat.edge_types)
|
|
149
|
+
theta_v = redundancy_ratio * base_cov_v / n_v if n_v else 0.0
|
|
150
|
+
theta_e = redundancy_ratio * base_cov_e / n_e if n_e else 0.0
|
|
151
|
+
if tables is None:
|
|
152
|
+
tables = CoverageTables(flat, similarity)
|
|
153
|
+
shortcuts = _Shortcuts(schema, flat)
|
|
154
|
+
reflattened: list[str] = []
|
|
155
|
+
|
|
156
|
+
def judge(type_id: str, cov_v_after: float, cov_e_after: float) -> Removal:
|
|
157
|
+
delta_v = base_cov_v - cov_v_after
|
|
158
|
+
delta_e = base_cov_e - cov_e_after
|
|
159
|
+
redundant = (delta_v < theta_v or delta_v <= 0) and (delta_e < theta_e or delta_e <= 0)
|
|
160
|
+
return Removal(type_id, delta_v, delta_e, redundant)
|
|
161
|
+
|
|
162
|
+
def again(type_id: str) -> Removal:
|
|
163
|
+
reflattened.append(type_id)
|
|
164
|
+
reduced = CoverageTables(flatten(reduce_schema(schema, type_id)), similarity)
|
|
165
|
+
return judge(type_id, reduced.cov_v_value, reduced.cov_e_value)
|
|
166
|
+
|
|
167
|
+
def node_removal(type_id: str) -> Removal:
|
|
168
|
+
taken = shortcuts.node(type_id)
|
|
169
|
+
if taken is None:
|
|
170
|
+
return again(type_id)
|
|
171
|
+
column, edges = taken
|
|
172
|
+
return judge(type_id, tables.cov_v_without(column), tables.cov_e_without(edges))
|
|
173
|
+
|
|
174
|
+
def edge_removal(type_id: str) -> Removal:
|
|
175
|
+
edges = shortcuts.edge(type_id)
|
|
176
|
+
if edges is None:
|
|
177
|
+
return again(type_id)
|
|
178
|
+
# The node types do not change, so Cov_V is computed from the same values.
|
|
179
|
+
return judge(type_id, tables.cov_v_value, tables.cov_e_without(edges))
|
|
180
|
+
|
|
181
|
+
node_removals = tuple(node_removal(n.id) for n in schema.node_types)
|
|
182
|
+
edge_removals = tuple(edge_removal(e.id) for e in schema.edge_types)
|
|
183
|
+
return ConcisenessResult(
|
|
184
|
+
con_v=_ratio(sum(r.redundant for r in node_removals), len(node_removals)),
|
|
185
|
+
con_e=_ratio(sum(r.redundant for r in edge_removals), len(edge_removals)),
|
|
186
|
+
theta_v=theta_v,
|
|
187
|
+
theta_e=theta_e,
|
|
188
|
+
node_removals=node_removals,
|
|
189
|
+
edge_removals=edge_removals,
|
|
190
|
+
reflattened=tuple(reflattened),
|
|
191
|
+
)
|