se-interpretive-kernel 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.
- se_interpretive_kernel/__init__.py +7 -0
- se_interpretive_kernel/_version.py +24 -0
- se_interpretive_kernel/artifact.py +76 -0
- se_interpretive_kernel/cases.py +120 -0
- se_interpretive_kernel/check.py +293 -0
- se_interpretive_kernel/cli.py +5 -0
- se_interpretive_kernel/demand.py +66 -0
- se_interpretive_kernel/differences.py +69 -0
- se_interpretive_kernel/explain.py +224 -0
- se_interpretive_kernel/generated.py +100 -0
- se_interpretive_kernel/kernel.py +52 -0
- se_interpretive_kernel/lifting.py +374 -0
- se_interpretive_kernel/literal.py +87 -0
- se_interpretive_kernel/loader.py +238 -0
- se_interpretive_kernel/partition.py +363 -0
- se_interpretive_kernel/probes.py +124 -0
- se_interpretive_kernel/sampling.py +36 -0
- se_interpretive_kernel/witness.py +69 -0
- se_interpretive_kernel-0.1.0.dist-info/METADATA +261 -0
- se_interpretive_kernel-0.1.0.dist-info/RECORD +23 -0
- se_interpretive_kernel-0.1.0.dist-info/WHEEL +4 -0
- se_interpretive_kernel-0.1.0.dist-info/entry_points.txt +2 -0
- se_interpretive_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""lifting.py - Comparison liftings from output regimes to object regimes.
|
|
2
|
+
|
|
3
|
+
Interpretation returns structured outputs, and comparing two outputs requires
|
|
4
|
+
an equivalence on the atomic values they are built from.
|
|
5
|
+
A *comparison lifting* carries an output regime up to the structures:
|
|
6
|
+
|
|
7
|
+
C : Eq(O) -> Eq(X)
|
|
8
|
+
|
|
9
|
+
where ``O`` is the carrier of atomic outputs and ``X`` is the carrier of the
|
|
10
|
+
structured objects being compared.
|
|
11
|
+
|
|
12
|
+
The kernel construction requires ``C`` to preserve arbitrary meets.
|
|
13
|
+
Complete meet homomorphisms of this shape are exactly the ``Eq(O)``-valued
|
|
14
|
+
generalized ultrametrics on ``X``: every one of them is given by a cost
|
|
15
|
+
|
|
16
|
+
delta : X x X -> Eq(O)
|
|
17
|
+
|
|
18
|
+
assigning to each pair the least output identification that would make the
|
|
19
|
+
two objects the same, with
|
|
20
|
+
|
|
21
|
+
C(rho) = { (x, y) : delta(x, y) refines rho }.
|
|
22
|
+
|
|
23
|
+
The cost must satisfy three laws:
|
|
24
|
+
|
|
25
|
+
1. ``delta(x, x)`` is the discrete partition,
|
|
26
|
+
2. ``delta(x, y) == delta(y, x)``, and
|
|
27
|
+
3. ``delta(x, z)`` refines ``delta(x, y) join delta(y, z)``.
|
|
28
|
+
|
|
29
|
+
A lifting that satisfies them supports the adjunction.
|
|
30
|
+
A comparison that only claims to be a lifting does not, and
|
|
31
|
+
:func:`check_lifting_laws` is the finite test that separates the two cases.
|
|
32
|
+
|
|
33
|
+
Two comparisons in this module are present because they fail:
|
|
34
|
+
|
|
35
|
+
- :class:`QuotientImage` compares unordered outputs by equality of their
|
|
36
|
+
quotient images. It is monotone but does not preserve meets, so no cost
|
|
37
|
+
function represents it.
|
|
38
|
+
- :class:`OptimalMatching` looks like the natural cost for unordered outputs
|
|
39
|
+
but violates the triangle law, because the meet over alternative bijections
|
|
40
|
+
cannot be moved through the join in a lattice that is not distributive.
|
|
41
|
+
|
|
42
|
+
:class:`IndexedPointwise` is the comparison this package selects.
|
|
43
|
+
When the outputs are genuinely unordered, a declared canonicalization
|
|
44
|
+
supplies the index, and the price of that choice is a cost no finer than the
|
|
45
|
+
best matching would have required.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from collections.abc import Callable, Sequence
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from itertools import permutations
|
|
53
|
+
from typing import Protocol
|
|
54
|
+
|
|
55
|
+
from .partition import Item, Pair, Partition, meet_all
|
|
56
|
+
|
|
57
|
+
type Form = Callable[[Item], Sequence[Item]]
|
|
58
|
+
"""Map an object to the indexed sequence of atomic outputs it presents."""
|
|
59
|
+
|
|
60
|
+
type Members = Callable[[Item], frozenset[Item]]
|
|
61
|
+
"""Map an object to the unordered atomic outputs it contains."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Comparison(Protocol):
|
|
65
|
+
"""Carry an output regime up to a regime on structured objects."""
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def objects(self) -> tuple[Item, ...]:
|
|
69
|
+
"""Return the carrier of structured objects being compared."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def induced(self, rho: Partition) -> Partition:
|
|
73
|
+
"""Return the object regime induced by an output regime."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Lifting(Protocol):
|
|
78
|
+
"""Assign each pair of objects the least output regime identifying them."""
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def objects(self) -> tuple[Item, ...]:
|
|
82
|
+
"""Return the carrier of structured objects being compared."""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def output_carrier(self) -> tuple[Item, ...]:
|
|
87
|
+
"""Return the carrier of atomic output values."""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
def cost(self, x: Item, y: Item) -> Partition:
|
|
91
|
+
"""Return the least output regime under which two objects agree."""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
def induced(self, rho: Partition) -> Partition:
|
|
95
|
+
"""Return the object regime induced by an output regime."""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class _CostBased:
|
|
101
|
+
"""Provide the induced comparison shared by every cost-based lifting.
|
|
102
|
+
|
|
103
|
+
Two objects are related under an output regime exactly when the cost of
|
|
104
|
+
identifying them refines that regime.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def _induced_from_cost(
|
|
108
|
+
self,
|
|
109
|
+
objects: tuple[Item, ...],
|
|
110
|
+
cost: Callable[[Item, Item], Partition],
|
|
111
|
+
rho: Partition,
|
|
112
|
+
) -> Partition:
|
|
113
|
+
"""Return the object regime generated by pairs whose cost fits ``rho``.
|
|
114
|
+
|
|
115
|
+
Only pairs are collected here.
|
|
116
|
+
Their equivalence closure is taken by :meth:`Partition.from_pairs`,
|
|
117
|
+
which is sound because the triangle law makes the relation transitive
|
|
118
|
+
already when the laws hold, and because closing a relation that is
|
|
119
|
+
already transitive changes nothing.
|
|
120
|
+
"""
|
|
121
|
+
pairs: list[Pair] = [
|
|
122
|
+
(x, y)
|
|
123
|
+
for i, x in enumerate(objects)
|
|
124
|
+
for y in objects[i + 1 :]
|
|
125
|
+
if cost(x, y).refines(rho)
|
|
126
|
+
]
|
|
127
|
+
return Partition.from_pairs(objects, pairs)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class IndexedPointwise(_CostBased):
|
|
132
|
+
"""Compare objects position by position over a declared index.
|
|
133
|
+
|
|
134
|
+
``form`` presents each object as a sequence of atomic outputs.
|
|
135
|
+
Two objects are compared by requiring agreement at every position, so the
|
|
136
|
+
cost of identifying them is generated by the pairs of values standing at
|
|
137
|
+
corresponding positions.
|
|
138
|
+
|
|
139
|
+
This comparison preserves arbitrary meets, because agreement at every
|
|
140
|
+
position is a conjunction of atomic comparisons and universal
|
|
141
|
+
quantification commutes with meet.
|
|
142
|
+
|
|
143
|
+
When the underlying outputs are unordered, ``form`` is a declared
|
|
144
|
+
canonicalization and is part of the declaration rather than an
|
|
145
|
+
implementation detail.
|
|
146
|
+
A canonicalization that is not injective identifies distinct objects at
|
|
147
|
+
zero cost, which declares an identity without ever appearing as a declared
|
|
148
|
+
identity regime.
|
|
149
|
+
|
|
150
|
+
Sequences compared here must already have equal length.
|
|
151
|
+
Length mismatch is a declared comparison outcome and belongs to the
|
|
152
|
+
tagged outcome domain, not to this module.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
output_carrier: tuple[Item, ...]
|
|
156
|
+
objects: tuple[Item, ...]
|
|
157
|
+
form: Form
|
|
158
|
+
|
|
159
|
+
def cost(self, x: Item, y: Item) -> Partition:
|
|
160
|
+
"""Return the closure of the pairs standing at corresponding positions."""
|
|
161
|
+
left, right = self.form(x), self.form(y)
|
|
162
|
+
if len(left) != len(right):
|
|
163
|
+
msg = (
|
|
164
|
+
"indexed comparison requires equal-length forms; "
|
|
165
|
+
"length mismatch is a tagged outcome, not a comparison"
|
|
166
|
+
)
|
|
167
|
+
raise ValueError(msg)
|
|
168
|
+
return Partition.from_pairs(self.output_carrier, zip(left, right, strict=True))
|
|
169
|
+
|
|
170
|
+
def induced(self, rho: Partition) -> Partition:
|
|
171
|
+
"""Return the object regime induced by an output regime."""
|
|
172
|
+
return self._induced_from_cost(self.objects, self.cost, rho)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class OptimalMatching(_CostBased):
|
|
177
|
+
"""Compare unordered objects by the cheapest bijection between them.
|
|
178
|
+
|
|
179
|
+
The cost is the meet, over all bijections between the two member sets, of
|
|
180
|
+
the identification each bijection would require.
|
|
181
|
+
This is the natural reading of "there is a matching under which the two
|
|
182
|
+
agree", and it is the comparison a practitioner would reach for first.
|
|
183
|
+
|
|
184
|
+
It is not a lifting.
|
|
185
|
+
The triangle law fails, because moving the meet over alternative
|
|
186
|
+
bijections through the join requires a distributivity the partition
|
|
187
|
+
lattice does not have.
|
|
188
|
+
:func:`check_lifting_laws` returns the counterexample.
|
|
189
|
+
|
|
190
|
+
The class is retained because the failure is a result: optimal matching
|
|
191
|
+
and meet preservation cannot be had together, and a declared
|
|
192
|
+
canonicalization is the price of keeping the second.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
output_carrier: tuple[Item, ...]
|
|
196
|
+
objects: tuple[Item, ...]
|
|
197
|
+
members: Members
|
|
198
|
+
|
|
199
|
+
def cost(self, x: Item, y: Item) -> Partition:
|
|
200
|
+
"""Return the meet over all bijections of the cost each would require.
|
|
201
|
+
|
|
202
|
+
Member sets of different sizes admit no bijection.
|
|
203
|
+
The meet of an empty family is the top element, which reports that
|
|
204
|
+
only the total identification makes the two objects agree.
|
|
205
|
+
"""
|
|
206
|
+
left, right = (
|
|
207
|
+
sorted(self.members(x), key=repr),
|
|
208
|
+
sorted(self.members(y), key=repr),
|
|
209
|
+
)
|
|
210
|
+
if len(left) != len(right):
|
|
211
|
+
return Partition.indiscrete(self.output_carrier)
|
|
212
|
+
candidates = [
|
|
213
|
+
Partition.from_pairs(self.output_carrier, zip(left, perm, strict=True))
|
|
214
|
+
for perm in permutations(right)
|
|
215
|
+
]
|
|
216
|
+
return meet_all(self.output_carrier, candidates)
|
|
217
|
+
|
|
218
|
+
def induced(self, rho: Partition) -> Partition:
|
|
219
|
+
"""Return the object regime induced by an output regime."""
|
|
220
|
+
return self._induced_from_cost(self.objects, self.cost, rho)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True)
|
|
224
|
+
class QuotientImage:
|
|
225
|
+
"""Compare unordered objects by equality of their quotient images.
|
|
226
|
+
|
|
227
|
+
Two objects are related under an output regime when the sets of output
|
|
228
|
+
classes they occupy are equal.
|
|
229
|
+
|
|
230
|
+
This comparison is monotone: collapsing output classes further cannot
|
|
231
|
+
separate objects whose images already agreed.
|
|
232
|
+
It does not preserve meets, so it is not represented by any cost function
|
|
233
|
+
and does not support the adjunction.
|
|
234
|
+
:func:`check_meet_preservation` returns the counterexample.
|
|
235
|
+
|
|
236
|
+
The obstruction is the existential matching hidden in comparing unordered
|
|
237
|
+
images, not the fact that the outputs are sets.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
output_carrier: tuple[Item, ...]
|
|
241
|
+
objects: tuple[Item, ...]
|
|
242
|
+
members: Members
|
|
243
|
+
|
|
244
|
+
def image(self, x: Item, rho: Partition) -> frozenset[int]:
|
|
245
|
+
"""Return the set of output classes the object's members occupy."""
|
|
246
|
+
return frozenset(rho.label_of(m) for m in self.members(x))
|
|
247
|
+
|
|
248
|
+
def induced(self, rho: Partition) -> Partition:
|
|
249
|
+
"""Return the object regime relating objects with equal images."""
|
|
250
|
+
return Partition.from_labels(
|
|
251
|
+
self.objects, {x: self.image(x, rho) for x in self.objects}
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class LawViolation:
|
|
257
|
+
"""Report one failed law together with the values that witness it.
|
|
258
|
+
|
|
259
|
+
``law`` names the law that failed.
|
|
260
|
+
|
|
261
|
+
``witness`` contains the objects or output regimes the failure was found
|
|
262
|
+
on, in the order the law names them.
|
|
263
|
+
|
|
264
|
+
``detail`` states what was expected and what held instead.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
law: str
|
|
268
|
+
witness: tuple[Item, ...]
|
|
269
|
+
detail: str
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def check_lifting_laws(lifting: Lifting) -> tuple[LawViolation, ...]:
|
|
273
|
+
"""Return every violation of the three cost laws, with witnesses.
|
|
274
|
+
|
|
275
|
+
Identity, symmetry, and the triangle law are checked over every ordered
|
|
276
|
+
triple of objects.
|
|
277
|
+
The carriers are finite and small by construction, so the check is
|
|
278
|
+
exhaustive rather than sampled.
|
|
279
|
+
|
|
280
|
+
An empty result means the cost is an ``Eq(O)``-valued generalized
|
|
281
|
+
ultrametric, so the comparison it induces preserves arbitrary meets and
|
|
282
|
+
the threshold construction applies to it.
|
|
283
|
+
"""
|
|
284
|
+
objects = lifting.objects
|
|
285
|
+
bottom = Partition.discrete(lifting.output_carrier)
|
|
286
|
+
found: list[LawViolation] = []
|
|
287
|
+
|
|
288
|
+
for x in objects:
|
|
289
|
+
if lifting.cost(x, x) != bottom:
|
|
290
|
+
found.append(
|
|
291
|
+
LawViolation(
|
|
292
|
+
"identity",
|
|
293
|
+
(x,),
|
|
294
|
+
"cost of an object against itself is not the discrete partition",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
for i, x in enumerate(objects):
|
|
299
|
+
for y in objects[i + 1 :]:
|
|
300
|
+
if lifting.cost(x, y) != lifting.cost(y, x):
|
|
301
|
+
found.append(
|
|
302
|
+
LawViolation("symmetry", (x, y), "cost depends on argument order")
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
for x in objects:
|
|
306
|
+
for y in objects:
|
|
307
|
+
for z in objects:
|
|
308
|
+
direct = lifting.cost(x, z)
|
|
309
|
+
detour = lifting.cost(x, y).join(lifting.cost(y, z))
|
|
310
|
+
if not direct.refines(detour):
|
|
311
|
+
found.append(
|
|
312
|
+
LawViolation(
|
|
313
|
+
"triangle",
|
|
314
|
+
(x, y, z),
|
|
315
|
+
"direct cost is not refined by the cost through the "
|
|
316
|
+
"intermediate object",
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return tuple(found)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def check_meet_preservation(
|
|
324
|
+
comparison: Comparison, samples: Sequence[Partition]
|
|
325
|
+
) -> tuple[LawViolation, ...]:
|
|
326
|
+
"""Return every failure of meet preservation over the sampled regimes.
|
|
327
|
+
|
|
328
|
+
A comparison preserves meets when the regime induced by a meet of output
|
|
329
|
+
regimes equals the meet of the regimes each induces separately.
|
|
330
|
+
Failure means no cost function represents the comparison, so the
|
|
331
|
+
threshold construction and the adjunction are both unavailable for it.
|
|
332
|
+
"""
|
|
333
|
+
found: list[LawViolation] = []
|
|
334
|
+
for i, rho in enumerate(samples):
|
|
335
|
+
for sigma in samples[i + 1 :]:
|
|
336
|
+
together = comparison.induced(rho.meet(sigma))
|
|
337
|
+
apart = comparison.induced(rho).meet(comparison.induced(sigma))
|
|
338
|
+
if together != apart:
|
|
339
|
+
found.append(
|
|
340
|
+
LawViolation(
|
|
341
|
+
"meet-preservation",
|
|
342
|
+
(rho, sigma),
|
|
343
|
+
"the regime induced by the meet differs from the meet of "
|
|
344
|
+
"the induced regimes",
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
return tuple(found)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def check_monotonicity(
|
|
351
|
+
comparison: Comparison, samples: Sequence[Partition]
|
|
352
|
+
) -> tuple[LawViolation, ...]:
|
|
353
|
+
"""Return every failure of monotonicity over the sampled regimes.
|
|
354
|
+
|
|
355
|
+
Coarsening an output regime may only coarsen the regime it induces.
|
|
356
|
+
Monotonicity is strictly weaker than meet preservation, and a comparison
|
|
357
|
+
may satisfy it while still admitting no cost function.
|
|
358
|
+
Reporting the two separately keeps that distinction visible.
|
|
359
|
+
"""
|
|
360
|
+
found: list[LawViolation] = []
|
|
361
|
+
for rho in samples:
|
|
362
|
+
for sigma in samples:
|
|
363
|
+
if rho.refines(sigma) and not comparison.induced(rho).refines(
|
|
364
|
+
comparison.induced(sigma)
|
|
365
|
+
):
|
|
366
|
+
found.append(
|
|
367
|
+
LawViolation(
|
|
368
|
+
"monotonicity",
|
|
369
|
+
(rho, sigma),
|
|
370
|
+
"a coarser output regime induced a strictly finer object "
|
|
371
|
+
"regime",
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
return tuple(found)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""literal.py - Independent literal definitions, used as an oracle.
|
|
2
|
+
|
|
3
|
+
Every function here works on explicit sets of ordered pairs and uses a naive
|
|
4
|
+
fixed-point closure. Nothing here uses :class:`Partition` internals, labels, or
|
|
5
|
+
the union-find, so agreement with the partition-based modules is a real
|
|
6
|
+
cross-check rather than a restatement.
|
|
7
|
+
|
|
8
|
+
The functions are slow and intended only for small finite cases.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterable, Sequence
|
|
14
|
+
|
|
15
|
+
from .artifact import Artifact
|
|
16
|
+
from .partition import Item, Pair, Partition
|
|
17
|
+
|
|
18
|
+
type PairSet = frozenset[Pair]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pairs_of(partition: Partition) -> PairSet:
|
|
22
|
+
"""Return a partition as the explicit set of related ordered pairs."""
|
|
23
|
+
return frozenset(
|
|
24
|
+
(x, y)
|
|
25
|
+
for x in partition.carrier
|
|
26
|
+
for y in partition.carrier
|
|
27
|
+
if partition.same(x, y)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def equivalence_closure(carrier: Sequence[Item], pairs: Iterable[Pair]) -> PairSet:
|
|
32
|
+
"""Return the least equivalence relation containing ``pairs``.
|
|
33
|
+
|
|
34
|
+
Starts from the identity, adds the reverse of every pair, and repeats
|
|
35
|
+
composition until nothing new appears.
|
|
36
|
+
"""
|
|
37
|
+
relation: set[Pair] = {(x, x) for x in carrier}
|
|
38
|
+
for x, y in pairs:
|
|
39
|
+
relation.add((x, y))
|
|
40
|
+
relation.add((y, x))
|
|
41
|
+
changed = True
|
|
42
|
+
while changed:
|
|
43
|
+
changed = False
|
|
44
|
+
for x, y in list(relation):
|
|
45
|
+
for y2, z in list(relation):
|
|
46
|
+
if y == y2 and (x, z) not in relation:
|
|
47
|
+
relation.add((x, z))
|
|
48
|
+
changed = True
|
|
49
|
+
return frozenset(relation)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def kernel_pairs(artifact: Artifact, selected: Sequence[Item], rho: PairSet) -> PairSet:
|
|
53
|
+
"""Return {(F, F') | for every selected p, (I(p,F), I(p,F')) in rho}."""
|
|
54
|
+
return frozenset(
|
|
55
|
+
(f1, f2)
|
|
56
|
+
for f1 in artifact.frames
|
|
57
|
+
for f2 in artifact.frames
|
|
58
|
+
if all(
|
|
59
|
+
(artifact.interpret(p, f1), artifact.interpret(p, f2)) in rho
|
|
60
|
+
for p in selected
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def generator_pairs(
|
|
66
|
+
artifact: Artifact, selected: Sequence[Item], phi: PairSet
|
|
67
|
+
) -> PairSet:
|
|
68
|
+
"""Return {(I(p,F), I(p,F')) | p selected and (F, F') in phi}."""
|
|
69
|
+
return frozenset(
|
|
70
|
+
(artifact.interpret(p, f1), artifact.interpret(p, f2))
|
|
71
|
+
for (f1, f2) in phi
|
|
72
|
+
for p in selected
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def generated_pairs(
|
|
77
|
+
artifact: Artifact, selected: Sequence[Item], phi: PairSet
|
|
78
|
+
) -> PairSet:
|
|
79
|
+
"""Return the equivalence closure of the generators of phi."""
|
|
80
|
+
return equivalence_closure(
|
|
81
|
+
artifact.outputs, generator_pairs(artifact, selected, phi)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_subset(smaller: PairSet, larger: PairSet) -> bool:
|
|
86
|
+
"""Return whether one relation is contained in another."""
|
|
87
|
+
return smaller <= larger
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""loader.py - Build an Artifact and a frame regime from tabular data.
|
|
2
|
+
|
|
3
|
+
The theory needs a total interpretation: every probe must produce an output for
|
|
4
|
+
every frame. Real tables have empty cells, so this module makes the answer to
|
|
5
|
+
"what does an empty cell produce" an explicit, reported choice instead of a
|
|
6
|
+
silent default.
|
|
7
|
+
|
|
8
|
+
Probe columns are supplied already computed. This module does not normalize,
|
|
9
|
+
clean, or interpret values. Normalization is the consumer's declared probe.
|
|
10
|
+
|
|
11
|
+
Missing values. An empty cell becomes the output :class:`Absent`. Two policies
|
|
12
|
+
decide whether different empty cells are the same output:
|
|
13
|
+
|
|
14
|
+
- ``MissingPolicy.DISTINCT`` (default): each empty cell is its own output. An
|
|
15
|
+
empty cell never agrees with anything, so an unknown value is not treated as
|
|
16
|
+
evidence of sameness.
|
|
17
|
+
- ``MissingPolicy.SHARED``: all empty cells of a probe are one output. Records
|
|
18
|
+
with no value then agree with each other. In the generated regime this one
|
|
19
|
+
output can also join every value of every record that the frame regime groups
|
|
20
|
+
with an empty-cell record, so it can chain unrelated values together.
|
|
21
|
+
|
|
22
|
+
Neither policy is correct in general. Run both and compare the results.
|
|
23
|
+
|
|
24
|
+
Output space. By default an output carries the name of its probe column, so
|
|
25
|
+
equal text in two different columns is two different outputs. With
|
|
26
|
+
``shared_outputs=True`` equal text is one output across columns, which is
|
|
27
|
+
appropriate only when the probe columns are alternative readings of one field.
|
|
28
|
+
|
|
29
|
+
This module reports what was loaded. It does not judge whether a table, a
|
|
30
|
+
probe, or a clustering is right.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
36
|
+
import csv
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from enum import Enum
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from .artifact import Artifact
|
|
42
|
+
from .partition import Item, Partition
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MissingPolicy(Enum):
|
|
46
|
+
"""Decide whether different empty cells are the same output."""
|
|
47
|
+
|
|
48
|
+
DISTINCT = "distinct"
|
|
49
|
+
SHARED = "shared"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Observed:
|
|
54
|
+
"""Contain an output that was read from a non-empty cell.
|
|
55
|
+
|
|
56
|
+
``scope`` is the probe column, or None when outputs are shared across
|
|
57
|
+
columns.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
scope: str | None
|
|
61
|
+
text: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Absent:
|
|
66
|
+
"""Contain the output of an empty cell.
|
|
67
|
+
|
|
68
|
+
``frame`` identifies the cell under ``MissingPolicy.DISTINCT`` and is None
|
|
69
|
+
under ``MissingPolicy.SHARED``.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
scope: str | None
|
|
73
|
+
frame: Item | None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Loaded:
|
|
78
|
+
"""Contain a loaded artifact and what was decided while loading it."""
|
|
79
|
+
|
|
80
|
+
artifact: Artifact
|
|
81
|
+
policy: MissingPolicy
|
|
82
|
+
shared_outputs: bool
|
|
83
|
+
missing_counts: Mapping[str, int]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_artifact(
|
|
87
|
+
rows: Iterable[Mapping[str, str | None]],
|
|
88
|
+
id_column: str,
|
|
89
|
+
probe_columns: Sequence[str],
|
|
90
|
+
*,
|
|
91
|
+
missing_values: frozenset[str] = frozenset({""}),
|
|
92
|
+
policy: MissingPolicy = MissingPolicy.DISTINCT,
|
|
93
|
+
shared_outputs: bool = False,
|
|
94
|
+
label: str = "",
|
|
95
|
+
) -> Loaded:
|
|
96
|
+
"""Return an artifact whose frames are the row ids and probes are columns.
|
|
97
|
+
|
|
98
|
+
A cell is empty when it is None or exactly one of ``missing_values``.
|
|
99
|
+
Values are compared exactly: no trimming or case folding is applied.
|
|
100
|
+
Frame order is row order. A duplicate or empty id is an error.
|
|
101
|
+
"""
|
|
102
|
+
columns = tuple(probe_columns)
|
|
103
|
+
if not columns:
|
|
104
|
+
msg = "at least one probe column is required"
|
|
105
|
+
raise ValueError(msg)
|
|
106
|
+
if len(set(columns)) != len(columns):
|
|
107
|
+
msg = "probe columns must be distinct"
|
|
108
|
+
raise ValueError(msg)
|
|
109
|
+
if id_column in columns:
|
|
110
|
+
msg = "the id column cannot also be a probe column"
|
|
111
|
+
raise ValueError(msg)
|
|
112
|
+
|
|
113
|
+
frames: list[Item] = []
|
|
114
|
+
seen: set[str] = set()
|
|
115
|
+
table: dict[tuple[Item, Item], Item] = {}
|
|
116
|
+
missing_counts = {column: 0 for column in columns}
|
|
117
|
+
outputs: dict[Item, None] = {}
|
|
118
|
+
|
|
119
|
+
for number, row in enumerate(rows, start=1):
|
|
120
|
+
if id_column not in row:
|
|
121
|
+
msg = f"row {number}: no column {id_column!r}"
|
|
122
|
+
raise ValueError(msg)
|
|
123
|
+
frame = row[id_column]
|
|
124
|
+
if frame is None or frame == "":
|
|
125
|
+
msg = f"row {number}: empty id"
|
|
126
|
+
raise ValueError(msg)
|
|
127
|
+
if frame in seen:
|
|
128
|
+
msg = f"row {number}: duplicate id {frame!r}"
|
|
129
|
+
raise ValueError(msg)
|
|
130
|
+
seen.add(frame)
|
|
131
|
+
frames.append(frame)
|
|
132
|
+
for column in columns:
|
|
133
|
+
if column not in row:
|
|
134
|
+
msg = f"row {number}: no column {column!r}"
|
|
135
|
+
raise ValueError(msg)
|
|
136
|
+
text = row[column]
|
|
137
|
+
scope = None if shared_outputs else column
|
|
138
|
+
output: Item
|
|
139
|
+
if text is None or text in missing_values:
|
|
140
|
+
missing_counts[column] += 1
|
|
141
|
+
cell = frame if policy is MissingPolicy.DISTINCT else None
|
|
142
|
+
output = Absent(scope, cell)
|
|
143
|
+
else:
|
|
144
|
+
output = Observed(scope, text)
|
|
145
|
+
table[(column, frame)] = output
|
|
146
|
+
outputs[output] = None
|
|
147
|
+
|
|
148
|
+
if not frames:
|
|
149
|
+
msg = "no rows"
|
|
150
|
+
raise ValueError(msg)
|
|
151
|
+
artifact = Artifact(tuple(frames), tuple(outputs), columns, table, label)
|
|
152
|
+
return Loaded(artifact, policy, shared_outputs, missing_counts)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _read_csv(path: str | Path, required: Sequence[str]) -> list[dict[str, str | None]]:
|
|
156
|
+
"""Return the rows of a UTF-8 CSV file, checking that columns exist."""
|
|
157
|
+
with Path(path).open(newline="", encoding="utf-8") as handle:
|
|
158
|
+
reader = csv.DictReader(handle)
|
|
159
|
+
header = reader.fieldnames or []
|
|
160
|
+
for column in required:
|
|
161
|
+
if column not in header:
|
|
162
|
+
msg = f"column {column!r} not found in {path}"
|
|
163
|
+
raise ValueError(msg)
|
|
164
|
+
return [dict(row) for row in reader]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def load_artifact_csv(
|
|
168
|
+
path: str | Path,
|
|
169
|
+
id_column: str,
|
|
170
|
+
probe_columns: Sequence[str],
|
|
171
|
+
*,
|
|
172
|
+
missing_values: frozenset[str] = frozenset({""}),
|
|
173
|
+
policy: MissingPolicy = MissingPolicy.DISTINCT,
|
|
174
|
+
shared_outputs: bool = False,
|
|
175
|
+
label: str = "",
|
|
176
|
+
) -> Loaded:
|
|
177
|
+
"""Return :func:`load_artifact` applied to the rows of a UTF-8 CSV file."""
|
|
178
|
+
rows = _read_csv(path, [id_column, *probe_columns])
|
|
179
|
+
return load_artifact(
|
|
180
|
+
rows,
|
|
181
|
+
id_column,
|
|
182
|
+
probe_columns,
|
|
183
|
+
missing_values=missing_values,
|
|
184
|
+
policy=policy,
|
|
185
|
+
shared_outputs=shared_outputs,
|
|
186
|
+
label=label,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def regime_from_rows(
|
|
191
|
+
rows: Iterable[Mapping[str, str | None]],
|
|
192
|
+
frames: Sequence[Item],
|
|
193
|
+
id_column: str,
|
|
194
|
+
cluster_column: str,
|
|
195
|
+
) -> Partition:
|
|
196
|
+
"""Return the frame regime declared by a clustering column.
|
|
197
|
+
|
|
198
|
+
Records with the same cluster value are in one block. A record with an empty
|
|
199
|
+
cluster value is alone in its own block. The clustering is taken as declared
|
|
200
|
+
by whoever produced it; nothing here judges it.
|
|
201
|
+
|
|
202
|
+
Every frame must appear exactly once, and no other ids may appear.
|
|
203
|
+
"""
|
|
204
|
+
labels: dict[Item, object] = {}
|
|
205
|
+
for number, row in enumerate(rows, start=1):
|
|
206
|
+
frame = row.get(id_column)
|
|
207
|
+
if frame is None or frame == "":
|
|
208
|
+
msg = f"row {number}: empty id"
|
|
209
|
+
raise ValueError(msg)
|
|
210
|
+
if frame in labels:
|
|
211
|
+
msg = f"row {number}: duplicate id {frame!r}"
|
|
212
|
+
raise ValueError(msg)
|
|
213
|
+
cluster = row.get(cluster_column)
|
|
214
|
+
if cluster is None or cluster == "":
|
|
215
|
+
labels[frame] = ("unclustered", frame)
|
|
216
|
+
else:
|
|
217
|
+
labels[frame] = ("cluster", cluster)
|
|
218
|
+
known = set(frames)
|
|
219
|
+
extra = [frame for frame in labels if frame not in known]
|
|
220
|
+
if extra:
|
|
221
|
+
msg = f"ids not in the artifact: {extra[:3]!r}"
|
|
222
|
+
raise ValueError(msg)
|
|
223
|
+
absent = [frame for frame in frames if frame not in labels]
|
|
224
|
+
if absent:
|
|
225
|
+
msg = f"artifact frames with no clustering row: {absent[:3]!r}"
|
|
226
|
+
raise ValueError(msg)
|
|
227
|
+
return Partition.from_labels(frames, labels)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def load_regime_csv(
|
|
231
|
+
path: str | Path,
|
|
232
|
+
frames: Sequence[Item],
|
|
233
|
+
id_column: str,
|
|
234
|
+
cluster_column: str,
|
|
235
|
+
) -> Partition:
|
|
236
|
+
"""Return :func:`regime_from_rows` applied to the rows of a UTF-8 CSV file."""
|
|
237
|
+
rows = _read_csv(path, [id_column, cluster_column])
|
|
238
|
+
return regime_from_rows(rows, frames, id_column, cluster_column)
|