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.
@@ -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)