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 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
+ )