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,363 @@
|
|
|
1
|
+
"""partition.py - Finite partition algebra over a declared carrier.
|
|
2
|
+
|
|
3
|
+
This module provides the lattice operations the interpretive kernel is built
|
|
4
|
+
from and knows nothing about interpretation, frames, probes, or occurrences.
|
|
5
|
+
It imports nothing else in this package.
|
|
6
|
+
|
|
7
|
+
A :class:`Partition` is an equivalence relation on a finite ordered carrier,
|
|
8
|
+
stored as a canonical block label for each carrier position.
|
|
9
|
+
Two partitions over the same carrier are equal exactly when they induce the
|
|
10
|
+
same blocks, regardless of how their blocks were originally labeled.
|
|
11
|
+
|
|
12
|
+
The order is refinement.
|
|
13
|
+
``a.refines(b)`` holds when every pair related by ``a`` is also related by
|
|
14
|
+
``b``, so finer partitions are lower.
|
|
15
|
+
The discrete partition is the bottom element and
|
|
16
|
+
the one-block partition is the top element.
|
|
17
|
+
Meet is intersection and join is the equivalence closure of the union.
|
|
18
|
+
|
|
19
|
+
This layer is written to be extractable.
|
|
20
|
+
It has no dependencies beyond the standard library and no vocabulary from the
|
|
21
|
+
layers above it.
|
|
22
|
+
Extraction is not justified until a second consumer needs this same public
|
|
23
|
+
surface.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Hashable, Iterable, Iterator, Mapping, Sequence
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from itertools import pairwise
|
|
31
|
+
|
|
32
|
+
type Item = Hashable
|
|
33
|
+
"""One element of a carrier. Any hashable value may be used."""
|
|
34
|
+
|
|
35
|
+
type Pair = tuple[Item, Item]
|
|
36
|
+
"""An unordered relationship between two carrier elements, written in order."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _UnionFind:
|
|
40
|
+
"""Maintain disjoint carrier groups generated by supplied pairs.
|
|
41
|
+
|
|
42
|
+
Each element begins in its own group.
|
|
43
|
+
Joining two elements merges their groups,
|
|
44
|
+
and finding an element returns the representative used as that
|
|
45
|
+
group's label.
|
|
46
|
+
|
|
47
|
+
Path compression shortens previously traversed parent chains so later
|
|
48
|
+
lookups require less work.
|
|
49
|
+
|
|
50
|
+
This class is duplicated in spirit across the Structural Explainability
|
|
51
|
+
repositories on purpose.
|
|
52
|
+
Where two implementations exist to check each other,
|
|
53
|
+
sharing this code would remove the independence that makes the comparison
|
|
54
|
+
meaningful.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, items: Iterable[Item]) -> None:
|
|
58
|
+
"""Create one singleton group for every supplied element."""
|
|
59
|
+
self.parent: dict[Item, Item] = {x: x for x in items}
|
|
60
|
+
|
|
61
|
+
def find(self, x: Item) -> Item:
|
|
62
|
+
"""Return the representative of the group containing ``x``.
|
|
63
|
+
|
|
64
|
+
The first loop follows parent links to the current root.
|
|
65
|
+
The second loop compresses the traversed path
|
|
66
|
+
so those elements point directly to that root.
|
|
67
|
+
"""
|
|
68
|
+
root = x
|
|
69
|
+
while self.parent[root] != root:
|
|
70
|
+
root = self.parent[root]
|
|
71
|
+
while self.parent[x] != root: # path compression
|
|
72
|
+
self.parent[x], x = root, self.parent[x]
|
|
73
|
+
return root
|
|
74
|
+
|
|
75
|
+
def union(self, a: Item, b: Item) -> None:
|
|
76
|
+
"""Merge the groups containing ``a`` and ``b``.
|
|
77
|
+
|
|
78
|
+
Nothing changes when the two elements already belong to the same
|
|
79
|
+
group.
|
|
80
|
+
"""
|
|
81
|
+
ra, rb = self.find(a), self.find(b)
|
|
82
|
+
if ra != rb:
|
|
83
|
+
self.parent[rb] = ra
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _canonical_labels(
|
|
87
|
+
carrier: Sequence[Item], raw: Mapping[Item, object]
|
|
88
|
+
) -> tuple[int, ...]:
|
|
89
|
+
"""Return block labels renumbered by first appearance in carrier order.
|
|
90
|
+
|
|
91
|
+
Any label values may be supplied.
|
|
92
|
+
They are replaced by integers assigned in the order their blocks are first
|
|
93
|
+
encountered, so two partitions with the same blocks receive the same
|
|
94
|
+
labels and compare equal.
|
|
95
|
+
"""
|
|
96
|
+
seen: dict[object, int] = {}
|
|
97
|
+
out: list[int] = []
|
|
98
|
+
for item in carrier:
|
|
99
|
+
key = raw[item]
|
|
100
|
+
if key not in seen:
|
|
101
|
+
seen[key] = len(seen)
|
|
102
|
+
out.append(seen[key])
|
|
103
|
+
return tuple(out)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Partition:
|
|
108
|
+
"""Represent one equivalence relation on a finite ordered carrier.
|
|
109
|
+
|
|
110
|
+
``carrier`` is the finite domain, in a fixed order.
|
|
111
|
+
Carrier order determines canonical block numbering and the order in which
|
|
112
|
+
witnesses are searched, so results are deterministic.
|
|
113
|
+
|
|
114
|
+
``labels`` gives the canonical block number of each carrier element, at
|
|
115
|
+
the same position as that element in ``carrier``.
|
|
116
|
+
|
|
117
|
+
Instances are constructed through the class methods rather than directly,
|
|
118
|
+
because those apply canonical renumbering.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
carrier: tuple[Item, ...]
|
|
122
|
+
labels: tuple[int, ...]
|
|
123
|
+
_position: Mapping[Item, int] = field(
|
|
124
|
+
init=False, repr=False, compare=False, default_factory=dict
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def __post_init__(self) -> None:
|
|
128
|
+
"""Reject a malformed partition and build the position index.
|
|
129
|
+
|
|
130
|
+
Carrier elements must be distinct and every element must carry a
|
|
131
|
+
label.
|
|
132
|
+
"""
|
|
133
|
+
if len(set(self.carrier)) != len(self.carrier):
|
|
134
|
+
msg = "carrier elements must be distinct"
|
|
135
|
+
raise ValueError(msg)
|
|
136
|
+
if len(self.labels) != len(self.carrier):
|
|
137
|
+
msg = "labels must supply one block number per carrier element"
|
|
138
|
+
raise ValueError(msg)
|
|
139
|
+
object.__setattr__(
|
|
140
|
+
self, "_position", {item: i for i, item in enumerate(self.carrier)}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# === Constructors ===
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_labels(
|
|
147
|
+
cls, carrier: Sequence[Item], labels: Mapping[Item, object]
|
|
148
|
+
) -> Partition:
|
|
149
|
+
"""Return the partition grouping elements that share a label.
|
|
150
|
+
|
|
151
|
+
Label values are arbitrary and are renumbered canonically, so callers
|
|
152
|
+
may use whatever grouping key is natural for their domain.
|
|
153
|
+
"""
|
|
154
|
+
seq = tuple(carrier)
|
|
155
|
+
return cls(seq, _canonical_labels(seq, labels))
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def discrete(cls, carrier: Sequence[Item]) -> Partition:
|
|
159
|
+
"""Return the finest partition, in which every element is alone.
|
|
160
|
+
|
|
161
|
+
This is the bottom element of the refinement order.
|
|
162
|
+
"""
|
|
163
|
+
seq = tuple(carrier)
|
|
164
|
+
return cls(seq, tuple(range(len(seq))))
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def indiscrete(cls, carrier: Sequence[Item]) -> Partition:
|
|
168
|
+
"""Return the coarsest partition, in which all elements are together.
|
|
169
|
+
|
|
170
|
+
This is the top element of the refinement order.
|
|
171
|
+
"""
|
|
172
|
+
seq = tuple(carrier)
|
|
173
|
+
return cls(seq, (0,) * len(seq))
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_pairs(cls, carrier: Sequence[Item], pairs: Iterable[Pair]) -> Partition:
|
|
177
|
+
"""Return the equivalence relation generated by the supplied pairs.
|
|
178
|
+
|
|
179
|
+
The supplied pairs need not be reflexive, symmetric, or transitive.
|
|
180
|
+
Their equivalence closure is computed by union-find, so only a
|
|
181
|
+
generating set has to be supplied.
|
|
182
|
+
|
|
183
|
+
This is the operation that makes the threshold construction cheap: a
|
|
184
|
+
spanning forest of a regime generates the same closure as the full
|
|
185
|
+
relation.
|
|
186
|
+
"""
|
|
187
|
+
seq = tuple(carrier)
|
|
188
|
+
uf = _UnionFind(seq)
|
|
189
|
+
for x, y in pairs:
|
|
190
|
+
uf.union(x, y)
|
|
191
|
+
return cls.from_labels(seq, {item: uf.find(item) for item in seq})
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def from_blocks(
|
|
195
|
+
cls, carrier: Sequence[Item], blocks: Iterable[Iterable[Item]]
|
|
196
|
+
) -> Partition:
|
|
197
|
+
"""Return the partition with the supplied blocks.
|
|
198
|
+
|
|
199
|
+
Elements of the carrier not named in any supplied block are placed in
|
|
200
|
+
singleton blocks of their own.
|
|
201
|
+
"""
|
|
202
|
+
seq = tuple(carrier)
|
|
203
|
+
pairs: list[Pair] = []
|
|
204
|
+
for block in blocks:
|
|
205
|
+
members = list(block)
|
|
206
|
+
pairs.extend(pairwise(members))
|
|
207
|
+
return cls.from_pairs(seq, pairs)
|
|
208
|
+
|
|
209
|
+
# === Queries ===
|
|
210
|
+
|
|
211
|
+
def blocks(self) -> tuple[tuple[Item, ...], ...]:
|
|
212
|
+
"""Return the blocks, each in carrier order, ordered by first member."""
|
|
213
|
+
groups: dict[int, list[Item]] = {}
|
|
214
|
+
for item, label in zip(self.carrier, self.labels, strict=True):
|
|
215
|
+
groups.setdefault(label, []).append(item)
|
|
216
|
+
return tuple(tuple(groups[k]) for k in sorted(groups))
|
|
217
|
+
|
|
218
|
+
def label_of(self, item: Item) -> int:
|
|
219
|
+
"""Return the canonical block number of one carrier element."""
|
|
220
|
+
return self.labels[self._position[item]]
|
|
221
|
+
|
|
222
|
+
def same(self, x: Item, y: Item) -> bool:
|
|
223
|
+
"""Return whether two carrier elements lie in the same block."""
|
|
224
|
+
return self.label_of(x) == self.label_of(y)
|
|
225
|
+
|
|
226
|
+
def refines(self, other: Partition) -> bool:
|
|
227
|
+
"""Return whether this partition refines ``other``.
|
|
228
|
+
|
|
229
|
+
Refinement holds when every pair this partition relates is also
|
|
230
|
+
related by ``other``.
|
|
231
|
+
A finer partition may make additional distinctions,
|
|
232
|
+
but it may not merge elements the coarser partition separates.
|
|
233
|
+
|
|
234
|
+
The check records the first ``other`` label seen for each of this
|
|
235
|
+
partition's labels and fails on a later conflict, so no pair of
|
|
236
|
+
carrier elements is enumerated.
|
|
237
|
+
"""
|
|
238
|
+
self._require_same_carrier(other)
|
|
239
|
+
seen: dict[int, int] = {}
|
|
240
|
+
for item in self.carrier:
|
|
241
|
+
fine, coarse = self.label_of(item), other.label_of(item)
|
|
242
|
+
if fine in seen:
|
|
243
|
+
if seen[fine] != coarse:
|
|
244
|
+
return False
|
|
245
|
+
else:
|
|
246
|
+
seen[fine] = coarse
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
def refinement_witness(self, other: Partition) -> Pair | None:
|
|
250
|
+
"""Return a pair showing why this partition does not refine ``other``.
|
|
251
|
+
|
|
252
|
+
The returned pair is related by this partition and separated by
|
|
253
|
+
``other``.
|
|
254
|
+
Carrier order is searched in order and the first such pair is
|
|
255
|
+
returned, so the witness is deterministic.
|
|
256
|
+
|
|
257
|
+
None is returned when this partition does refine ``other``.
|
|
258
|
+
"""
|
|
259
|
+
self._require_same_carrier(other)
|
|
260
|
+
anchor: dict[int, Item] = {}
|
|
261
|
+
for item in self.carrier:
|
|
262
|
+
fine = self.label_of(item)
|
|
263
|
+
if fine not in anchor:
|
|
264
|
+
anchor[fine] = item
|
|
265
|
+
elif not other.same(anchor[fine], item):
|
|
266
|
+
return (anchor[fine], item)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
# === Lattice operations ===
|
|
270
|
+
|
|
271
|
+
def meet(self, other: Partition) -> Partition:
|
|
272
|
+
"""Return the greatest lower bound: elements related by both.
|
|
273
|
+
|
|
274
|
+
Meet is intersection.
|
|
275
|
+
The result relates two elements exactly when this partition and
|
|
276
|
+
``other`` both relate them.
|
|
277
|
+
"""
|
|
278
|
+
self._require_same_carrier(other)
|
|
279
|
+
return Partition.from_labels(
|
|
280
|
+
self.carrier,
|
|
281
|
+
{
|
|
282
|
+
item: (self.label_of(item), other.label_of(item))
|
|
283
|
+
for item in self.carrier
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def join(self, other: Partition) -> Partition:
|
|
288
|
+
"""Return the least upper bound: the closure of both relations.
|
|
289
|
+
|
|
290
|
+
Join is the equivalence closure of the union.
|
|
291
|
+
Each partition contributes a spanning path through every one of its
|
|
292
|
+
blocks, which generates the same closure as the full relations.
|
|
293
|
+
"""
|
|
294
|
+
self._require_same_carrier(other)
|
|
295
|
+
pairs: list[Pair] = []
|
|
296
|
+
for part in (self, other):
|
|
297
|
+
for block in part.blocks():
|
|
298
|
+
pairs.extend(pairwise(block))
|
|
299
|
+
return Partition.from_pairs(self.carrier, pairs)
|
|
300
|
+
|
|
301
|
+
def _require_same_carrier(self, other: Partition) -> None:
|
|
302
|
+
"""Reject a comparison between partitions over different carriers.
|
|
303
|
+
|
|
304
|
+
Refinement is only defined between partitions of one domain.
|
|
305
|
+
Comparing across carriers is the mistyping this guard exists to
|
|
306
|
+
surface rather than silently permit.
|
|
307
|
+
"""
|
|
308
|
+
if self.carrier != other.carrier:
|
|
309
|
+
msg = "partitions must be over the same carrier, in the same order"
|
|
310
|
+
raise ValueError(msg)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def all_partitions(carrier: Sequence[Item]) -> Iterator[Partition]:
|
|
314
|
+
"""Yield every partition of a finite carrier, once each.
|
|
315
|
+
|
|
316
|
+
Partitions are generated as restricted growth strings, which are already
|
|
317
|
+
the canonical labeling, so each partition is produced exactly once.
|
|
318
|
+
|
|
319
|
+
The count is the Bell number of the carrier size and grows quickly.
|
|
320
|
+
This is intended for exhaustive law checking over the small carriers the
|
|
321
|
+
counterexamples and property tests use, not for enumeration at scale.
|
|
322
|
+
"""
|
|
323
|
+
seq = tuple(carrier)
|
|
324
|
+
size = len(seq)
|
|
325
|
+
if size == 0:
|
|
326
|
+
yield Partition(seq, ())
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
def grow(prefix: list[int], used: int) -> Iterator[tuple[int, ...]]:
|
|
330
|
+
if len(prefix) == size:
|
|
331
|
+
yield tuple(prefix)
|
|
332
|
+
return
|
|
333
|
+
for label in range(used + 1):
|
|
334
|
+
prefix.append(label)
|
|
335
|
+
yield from grow(prefix, max(used, label + 1))
|
|
336
|
+
prefix.pop()
|
|
337
|
+
|
|
338
|
+
for labels in grow([], 0):
|
|
339
|
+
yield Partition(seq, labels)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def meet_all(carrier: Sequence[Item], parts: Iterable[Partition]) -> Partition:
|
|
343
|
+
"""Return the meet of every supplied partition over a carrier.
|
|
344
|
+
|
|
345
|
+
An empty collection yields the top element, which is the identity for
|
|
346
|
+
meet.
|
|
347
|
+
"""
|
|
348
|
+
result = Partition.indiscrete(carrier)
|
|
349
|
+
for part in parts:
|
|
350
|
+
result = result.meet(part)
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def join_all(carrier: Sequence[Item], parts: Iterable[Partition]) -> Partition:
|
|
355
|
+
"""Return the join of every supplied partition over a carrier.
|
|
356
|
+
|
|
357
|
+
An empty collection yields the bottom element, which is the identity for
|
|
358
|
+
join.
|
|
359
|
+
"""
|
|
360
|
+
result = Partition.discrete(carrier)
|
|
361
|
+
for part in parts:
|
|
362
|
+
result = result.join(part)
|
|
363
|
+
return result
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""probes.py - Probe sensitivity of the kernel and the threshold.
|
|
2
|
+
|
|
3
|
+
Every result in this package is relative to the declared probe family Q. This
|
|
4
|
+
module reports how much each result depends on which probes are in Q.
|
|
5
|
+
|
|
6
|
+
For the kernel K_Q(rho):
|
|
7
|
+
|
|
8
|
+
- :func:`separating_probes` lists the probes that tell two frames apart.
|
|
9
|
+
- :func:`sole_separated_pairs` lists, for each probe, the frame pairs that no
|
|
10
|
+
other selected probe separates.
|
|
11
|
+
- :func:`load_bearing_probes` lists the probes whose removal changes the
|
|
12
|
+
kernel.
|
|
13
|
+
- :func:`minimal_probe_sets` lists every smallest-by-inclusion subset of Q that
|
|
14
|
+
gives the same kernel.
|
|
15
|
+
|
|
16
|
+
For the threshold L_Q(phi):
|
|
17
|
+
|
|
18
|
+
- :func:`probe_contributions` gives the threshold each probe forces alone. By
|
|
19
|
+
probe-union duality (Lean: ``liftRel_probeUnion``), L_Q(phi) is the join of
|
|
20
|
+
these.
|
|
21
|
+
|
|
22
|
+
Nothing here decides whether the probe family is complete. It reports how the
|
|
23
|
+
answer moves when probes are added or removed.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from itertools import combinations
|
|
30
|
+
|
|
31
|
+
from .artifact import Artifact
|
|
32
|
+
from .generated import generated
|
|
33
|
+
from .kernel import kernel
|
|
34
|
+
from .partition import Item, Partition
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def separating_probes(
|
|
38
|
+
artifact: Artifact,
|
|
39
|
+
selected: Sequence[Item],
|
|
40
|
+
rho: Partition,
|
|
41
|
+
x: Item,
|
|
42
|
+
y: Item,
|
|
43
|
+
) -> tuple[Item, ...]:
|
|
44
|
+
"""Return the selected probes whose outputs for x and y rho does not relate.
|
|
45
|
+
|
|
46
|
+
The frames x and y are related by K_Q(rho) exactly when this is empty.
|
|
47
|
+
"""
|
|
48
|
+
artifact.require_selected(selected)
|
|
49
|
+
if rho.carrier != artifact.outputs:
|
|
50
|
+
msg = "rho must be a partition of the artifact's outputs, in order"
|
|
51
|
+
raise ValueError(msg)
|
|
52
|
+
return tuple(
|
|
53
|
+
probe
|
|
54
|
+
for probe in selected
|
|
55
|
+
if not rho.same(artifact.interpret(probe, x), artifact.interpret(probe, y))
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sole_separated_pairs(
|
|
60
|
+
artifact: Artifact, selected: Sequence[Item], rho: Partition
|
|
61
|
+
) -> dict[Item, list[tuple[Item, Item]]]:
|
|
62
|
+
"""Return, for each selected probe, the frame pairs only that probe separates.
|
|
63
|
+
|
|
64
|
+
A pair listed under probe p is related by K_Q(rho) minus p and separated by
|
|
65
|
+
K_Q(rho): removing p from the family would merge the two frames. Pairs are
|
|
66
|
+
listed once, in frame order. Every selected probe appears as a key, with an
|
|
67
|
+
empty list when it is the sole separator of nothing.
|
|
68
|
+
"""
|
|
69
|
+
result: dict[Item, list[tuple[Item, Item]]] = {probe: [] for probe in selected}
|
|
70
|
+
frames = artifact.frames
|
|
71
|
+
for i, x in enumerate(frames):
|
|
72
|
+
for y in frames[i + 1 :]:
|
|
73
|
+
separators = separating_probes(artifact, selected, rho, x, y)
|
|
74
|
+
if len(separators) == 1:
|
|
75
|
+
result[separators[0]].append((x, y))
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_bearing_probes(
|
|
80
|
+
artifact: Artifact, selected: Sequence[Item], rho: Partition
|
|
81
|
+
) -> tuple[Item, ...]:
|
|
82
|
+
"""Return the selected probes whose removal changes K_Q(rho).
|
|
83
|
+
|
|
84
|
+
Because the kernel only gets coarser as probes are removed, a load-bearing
|
|
85
|
+
probe belongs to every minimal probe set.
|
|
86
|
+
"""
|
|
87
|
+
full = kernel(artifact, selected, rho)
|
|
88
|
+
return tuple(
|
|
89
|
+
probe
|
|
90
|
+
for probe in selected
|
|
91
|
+
if kernel(artifact, [p for p in selected if p != probe], rho) != full
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def minimal_probe_sets(
|
|
96
|
+
artifact: Artifact, selected: Sequence[Item], rho: Partition
|
|
97
|
+
) -> list[tuple[Item, ...]]:
|
|
98
|
+
"""Return every subset of Q that gives the same kernel and has no smaller one.
|
|
99
|
+
|
|
100
|
+
The search is exhaustive over subsets, smallest first, so it is intended
|
|
101
|
+
for small probe families. :func:`load_bearing_probes` and
|
|
102
|
+
:func:`sole_separated_pairs` scale polynomially and give the same
|
|
103
|
+
information about single probes.
|
|
104
|
+
"""
|
|
105
|
+
target = kernel(artifact, selected, rho)
|
|
106
|
+
found: list[tuple[Item, ...]] = []
|
|
107
|
+
for size in range(len(selected) + 1):
|
|
108
|
+
for subset in combinations(selected, size):
|
|
109
|
+
if any(set(smaller) <= set(subset) for smaller in found):
|
|
110
|
+
continue
|
|
111
|
+
if kernel(artifact, subset, rho) == target:
|
|
112
|
+
found.append(subset)
|
|
113
|
+
return found
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def probe_contributions(
|
|
117
|
+
artifact: Artifact, selected: Sequence[Item], phi: Partition
|
|
118
|
+
) -> dict[Item, Partition]:
|
|
119
|
+
"""Return, for each selected probe, the threshold that probe forces alone.
|
|
120
|
+
|
|
121
|
+
The threshold of the whole family is the join of these partitions.
|
|
122
|
+
"""
|
|
123
|
+
artifact.require_selected(selected)
|
|
124
|
+
return {probe: generated(artifact, (probe,), phi) for probe in selected}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""sampling.py - Random small artifacts for property checks.
|
|
2
|
+
|
|
3
|
+
Deterministic for a given seed. Sizes are kept small so that exhaustive
|
|
4
|
+
enumeration of partitions of the outputs and the frames stays cheap.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from random import Random
|
|
11
|
+
|
|
12
|
+
from .artifact import Artifact
|
|
13
|
+
from .partition import Item
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def random_artifact(
|
|
17
|
+
rng: Random, max_frames: int = 4, max_outputs: int = 4, max_probes: int = 3
|
|
18
|
+
) -> Artifact:
|
|
19
|
+
"""Return a random total artifact of small size."""
|
|
20
|
+
n_frames = rng.randint(1, max_frames)
|
|
21
|
+
n_outputs = rng.randint(1, max_outputs)
|
|
22
|
+
n_probes = rng.randint(0, max_probes)
|
|
23
|
+
frames = tuple(range(n_frames))
|
|
24
|
+
outputs = tuple(range(n_outputs))
|
|
25
|
+
probes = tuple(f"p{i}" for i in range(n_probes))
|
|
26
|
+
table: dict[tuple[Item, Item], Item] = {
|
|
27
|
+
(p, f): rng.choice(outputs) for p in probes for f in frames
|
|
28
|
+
}
|
|
29
|
+
return Artifact(frames, outputs, probes, table, "random")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def random_artifacts(count: int, seed: int) -> Iterator[Artifact]:
|
|
33
|
+
"""Yield ``count`` random artifacts for the given seed."""
|
|
34
|
+
rng = Random(seed)
|
|
35
|
+
for _ in range(count):
|
|
36
|
+
yield random_artifact(rng)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""witness.py - Finite incompatibility witnesses.
|
|
2
|
+
|
|
3
|
+
A witness against coherence of phi with rho (Lean:
|
|
4
|
+
``IncompatibilityWitness``) consists of two frames related by phi, a selected
|
|
5
|
+
probe, and the two outputs, which rho does not relate.
|
|
6
|
+
|
|
7
|
+
A witness shows that the supplied phi, rho, probes, and interpretation are
|
|
8
|
+
incompatible. It does not show which of them is the faulty choice.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from .artifact import Artifact
|
|
17
|
+
from .partition import Item, Partition
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Witness:
|
|
22
|
+
"""Contain one finite incompatibility witness."""
|
|
23
|
+
|
|
24
|
+
frame_1: Item
|
|
25
|
+
frame_2: Item
|
|
26
|
+
probe: Item
|
|
27
|
+
output_1: Item
|
|
28
|
+
output_2: Item
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_witness(
|
|
32
|
+
artifact: Artifact, selected: Sequence[Item], phi: Partition, rho: Partition
|
|
33
|
+
) -> Witness | None:
|
|
34
|
+
"""Return a witness against coherence, or None when phi is coherent.
|
|
35
|
+
|
|
36
|
+
Within each block of phi the first frame is compared with every later
|
|
37
|
+
frame. This finds a witness whenever one exists: if the first frame agrees
|
|
38
|
+
with each later frame under every selected probe, transitivity of rho gives
|
|
39
|
+
agreement for every pair in the block.
|
|
40
|
+
"""
|
|
41
|
+
artifact.require_selected(selected)
|
|
42
|
+
for block in phi.blocks():
|
|
43
|
+
anchor = block[0]
|
|
44
|
+
for frame in block[1:]:
|
|
45
|
+
for probe in selected:
|
|
46
|
+
out_anchor = artifact.interpret(probe, anchor)
|
|
47
|
+
out_frame = artifact.interpret(probe, frame)
|
|
48
|
+
if not rho.same(out_anchor, out_frame):
|
|
49
|
+
return Witness(anchor, frame, probe, out_anchor, out_frame)
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def witness_is_valid(
|
|
54
|
+
artifact: Artifact,
|
|
55
|
+
selected: Sequence[Item],
|
|
56
|
+
phi: Partition,
|
|
57
|
+
rho: Partition,
|
|
58
|
+
witness: Witness,
|
|
59
|
+
) -> bool:
|
|
60
|
+
"""Return whether a witness satisfies the definition of a witness."""
|
|
61
|
+
if witness.probe not in set(selected):
|
|
62
|
+
return False
|
|
63
|
+
if not phi.same(witness.frame_1, witness.frame_2):
|
|
64
|
+
return False
|
|
65
|
+
if artifact.interpret(witness.probe, witness.frame_1) != witness.output_1:
|
|
66
|
+
return False
|
|
67
|
+
if artifact.interpret(witness.probe, witness.frame_2) != witness.output_2:
|
|
68
|
+
return False
|
|
69
|
+
return not rho.same(witness.output_1, witness.output_2)
|