MESSY-synth 0.0.1__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,58 @@
1
+ """Generate structurally-faithful synthetic raw data from any MEDS-Extract v0.7 MESSY config.
2
+
3
+ A MESSY config is a complete, config-only ETL: it says where the raw data comes from, and how to
4
+ turn each raw table into MEDS events, using the `dftly <https://github.com/mmcdermott/dftly>`_
5
+ expression DSL. What it never says is what the raw tables *look like* — those are supposed to
6
+ already exist, behind a credentialed download.
7
+
8
+ MESSY_synth runs that reasoning backwards. It reads the config's expressions, deduces what the
9
+ source files must have contained, and writes a directory of synthetic files matching that shape:
10
+ right filenames, right columns, right dtypes, right string formats, right keys. The ETL then runs
11
+ over them end to end, with no access to the real dataset.
12
+
13
+ Every generated value is deliberately, visibly fake. The point is to reproduce a dataset's
14
+ *structure* so an ETL can be demonstrated and regression-tested — never to imitate its content.
15
+
16
+ Typical use::
17
+
18
+ from MESSY_synth import synthesize
19
+
20
+ result = synthesize("path/to/messy.yaml", "raw_synthetic/")
21
+ print(result.events) # per-event row counts from the dry run
22
+ """
23
+
24
+ from .constraints import ColumnConstraint, ConstraintSet, ValueKind
25
+ from .generate import GeneratedDataset, GenerationOptions, generate
26
+ from .inference import infer_constraints
27
+ from .plan import ColumnPlan, DatasetPlan, TablePlan, ValuePool, build_plan
28
+ from .smoke import SmokeResult, check_output, run_etl, smoke_test
29
+ from .synth import SynthesisResult, synthesize
30
+ from .validate import Finding, check_plan, dry_run, recommended_n_subjects
31
+ from .writer import FORMATS, write_dataset
32
+
33
+ __all__ = [
34
+ "FORMATS",
35
+ "ColumnConstraint",
36
+ "ColumnPlan",
37
+ "ConstraintSet",
38
+ "DatasetPlan",
39
+ "Finding",
40
+ "GeneratedDataset",
41
+ "GenerationOptions",
42
+ "SmokeResult",
43
+ "SynthesisResult",
44
+ "TablePlan",
45
+ "ValueKind",
46
+ "ValuePool",
47
+ "build_plan",
48
+ "check_output",
49
+ "check_plan",
50
+ "dry_run",
51
+ "generate",
52
+ "infer_constraints",
53
+ "recommended_n_subjects",
54
+ "run_etl",
55
+ "smoke_test",
56
+ "synthesize",
57
+ "write_dataset",
58
+ ]
@@ -0,0 +1,204 @@
1
+ """Command-line interface: ``MESSY-synth <spec> -o <dir>``.
2
+
3
+ The CLI is a thin wrapper over :func:`MESSY_synth.synth.synthesize`, with one addition worth
4
+ knowing about: it exits non-zero when validation finds an error. That makes it usable directly as a
5
+ CI smoke test for an ETL config — regenerate, and fail the build if any event stopped producing
6
+ rows.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import logging
13
+ import sys
14
+
15
+ from .synth import synthesize
16
+ from .writer import FORMATS
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def build_parser() -> argparse.ArgumentParser:
22
+ """Build the argument parser.
23
+
24
+ Returns:
25
+ The configured parser.
26
+
27
+ Examples:
28
+ >>> parser = build_parser()
29
+ >>> args = parser.parse_args(["messy.yaml", "-o", "out"])
30
+ >>> args.spec, args.output_dir, args.format, args.seed
31
+ ('messy.yaml', 'out', 'auto', 0)
32
+
33
+ Numeric range overrides are parsed into a mapping:
34
+
35
+ >>> parser.parse_args(["m.yaml", "-o", "o", "-R", "anchor_year=1950:2000"]).numeric_range
36
+ [('anchor_year', (1950.0, 2000.0))]
37
+ """
38
+ parser = argparse.ArgumentParser(
39
+ prog="MESSY-synth",
40
+ description=(
41
+ "Generate synthetic raw source data matching the structure a MEDS-Extract v0.7 "
42
+ "MESSY config expects. All generated values are fake; only the shape is real."
43
+ ),
44
+ )
45
+ parser.add_argument(
46
+ "spec",
47
+ help="Registered pipeline name, pkg:// reference, or path to a MESSY yaml file.",
48
+ )
49
+ parser.add_argument("-o", "--output-dir", required=True, help="Directory to write source files into.")
50
+ parser.add_argument(
51
+ "-n",
52
+ "--n-subjects",
53
+ type=int,
54
+ default=None,
55
+ help="Subject-universe size. Defaults to the smallest count that safely fills every split.",
56
+ )
57
+ parser.add_argument(
58
+ "-r", "--rows-per-subject", type=int, default=4, help="Rows per subject in timed event tables."
59
+ )
60
+ parser.add_argument("-v", "--vocab-size", type=int, default=8, help="Values per categorical vocabulary.")
61
+ parser.add_argument("-s", "--seed", type=int, default=0, help="Master seed; output is deterministic.")
62
+ parser.add_argument(
63
+ "--null-fraction",
64
+ type=float,
65
+ default=0.05,
66
+ help="Fraction of nullable values emitted as nulls, to exercise null-handling paths.",
67
+ )
68
+ parser.add_argument(
69
+ "-f", "--format", choices=FORMATS, default="auto", help="Output format for the source files."
70
+ )
71
+ parser.add_argument(
72
+ "-R",
73
+ "--numeric-range",
74
+ action="append",
75
+ type=_parse_range,
76
+ default=[],
77
+ metavar="COL=LO:HI",
78
+ help="Override the numeric range for a column, e.g. anchor_year=1950:2000. Repeatable.",
79
+ )
80
+ parser.add_argument("--explain", action="store_true", help="Print the inferred schema per column.")
81
+ parser.add_argument("--no-validate", action="store_true", help="Skip static checks and the dry run.")
82
+ parser.add_argument("-q", "--quiet", action="store_true", help="Only print errors.")
83
+ return parser
84
+
85
+
86
+ def _parse_range(text: str) -> tuple[str, tuple[float, float]]:
87
+ """Parse a ``COL=LO:HI`` numeric-range override.
88
+
89
+ Args:
90
+ text: The raw argument text.
91
+
92
+ Returns:
93
+ The column name and its bounds.
94
+
95
+ Raises:
96
+ argparse.ArgumentTypeError: If the text is malformed.
97
+
98
+ Examples:
99
+ >>> _parse_range("anchor_year=1950:2000")
100
+ ('anchor_year', (1950.0, 2000.0))
101
+ >>> _parse_range("bogus")
102
+ Traceback (most recent call last):
103
+ ...
104
+ argparse.ArgumentTypeError: Expected COL=LO:HI, got 'bogus'
105
+ """
106
+ column, _, bounds = text.partition("=")
107
+ lo, _, hi = bounds.partition(":")
108
+ if not column or not lo or not hi:
109
+ raise argparse.ArgumentTypeError(f"Expected COL=LO:HI, got {text!r}")
110
+ try:
111
+ return column, (float(lo), float(hi))
112
+ except ValueError as e:
113
+ raise argparse.ArgumentTypeError(f"Expected numeric bounds in {text!r}: {e}") from e
114
+
115
+
116
+ def main(argv: list[str] | None = None) -> int:
117
+ """Run the CLI.
118
+
119
+ Args:
120
+ argv: Argument list; defaults to ``sys.argv[1:]``.
121
+
122
+ Returns:
123
+ 0 on success, 1 if validation found an error.
124
+ """
125
+ args = build_parser().parse_args(argv)
126
+ logging.basicConfig(
127
+ level=logging.ERROR if args.quiet else logging.INFO,
128
+ format="%(message)s",
129
+ )
130
+
131
+ try:
132
+ result = synthesize(
133
+ args.spec,
134
+ args.output_dir,
135
+ n_subjects=args.n_subjects,
136
+ rows_per_subject=args.rows_per_subject,
137
+ vocab_size=args.vocab_size,
138
+ seed=args.seed,
139
+ null_fraction=args.null_fraction,
140
+ fmt=args.format,
141
+ numeric_ranges=dict(args.numeric_range),
142
+ validate=not args.no_validate,
143
+ )
144
+ except Exception as e:
145
+ # A config MEDS-Extract itself rejects is the answer, not a crash — reporting it as one
146
+ # failing line is what makes this usable as a CI gate.
147
+ print(f"ERROR config: {type(e).__name__}: {e}", file=sys.stderr)
148
+ return 1
149
+
150
+ if not args.quiet:
151
+ print(f"\nWrote {len(result.files)} file(s) to {result.output_dir} as {result.fmt}:")
152
+ print(result.dataset.summary())
153
+ if args.explain:
154
+ print("\nInferred source schema:")
155
+ print(explain(result))
156
+ if result.events is not None:
157
+ print("\nPer-event dry run:")
158
+ print(result.events)
159
+
160
+ for finding in result.findings:
161
+ print(finding, file=sys.stderr)
162
+
163
+ if not result.ok:
164
+ print(
165
+ "\nValidation failed. The generated data will not produce a complete MEDS dataset.",
166
+ file=sys.stderr,
167
+ )
168
+ return 1
169
+ return 0
170
+
171
+
172
+ def explain(result) -> str:
173
+ """Render the inferred per-column schema as a readable table.
174
+
175
+ Args:
176
+ result: A :class:`~MESSY_synth.synth.SynthesisResult`.
177
+
178
+ Returns:
179
+ The rendered text.
180
+ """
181
+ lines: list[str] = []
182
+ for table in result.dataset.plan.tables:
183
+ lines.append(f"\n {table.prefix} ({table.n_rows} rows, {table.kind})")
184
+ for column in table.columns:
185
+ c = column.constraint
186
+ extras = []
187
+ if c.datetime_formats:
188
+ extras.append("fmt=" + ",".join(c.datetime_formats))
189
+ if c.effective_temporal_role:
190
+ extras.append(f"role={c.effective_temporal_role}")
191
+ if column.pool_id:
192
+ extras.append("shared-pool" + (" (covering)" if column.covers_pool else ""))
193
+ if not c.effective_nullable:
194
+ extras.append("not-null")
195
+ lines.append(f" {column.name:<32} {c.kind.name:<13} {' '.join(extras)}")
196
+ # The notes are the actual derivation — which expression, in which event, imposed each
197
+ # constraint. Without them "why is this column a string?" has no answer.
198
+ for note in c.notes:
199
+ lines.append(f" <- {note}")
200
+ return "\n".join(lines)
201
+
202
+
203
+ if __name__ == "__main__": # pragma: no cover - entry point
204
+ raise SystemExit(main())
@@ -0,0 +1,351 @@
1
+ """The value-kind lattice used to describe what a synthetic source column must contain.
2
+
3
+ A MESSY config never states the schema of its own inputs. It states *expressions over* those
4
+ inputs, in the `dftly <https://github.com/mmcdermott/dftly>`_ DSL. This module defines the
5
+ vocabulary that :mod:`MESSY_synth.inference` uses to write down what it learns by walking those
6
+ expressions, plus the rules for combining two independent observations about the same column.
7
+
8
+ The central type is :class:`ColumnConstraint`. It is a *lattice element*: constraints combine with
9
+ :meth:`ColumnConstraint.merge`, which is associative and commutative, so the order in which a
10
+ config's expressions happen to be visited never changes the inferred schema.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field, replace
16
+ from enum import IntEnum
17
+
18
+
19
+ class ValueKind(IntEnum):
20
+ """What a column must hold, ordered by how tightly it pins the on-disk bytes.
21
+
22
+ The integer values *are* the merge precedence: when one column is observed twice with two
23
+ different kinds, the numerically larger kind wins. The ordering runs from "we learned nothing"
24
+ up to "the exact characters are determined".
25
+
26
+ The ordering is deliberate, not incidental:
27
+
28
+ - :attr:`DATETIME_STR` outranks everything because a ``strptime`` format determines the literal
29
+ characters in the file. A column read as ``$x::"%Y-%m-%d"`` *and* interpolated into an
30
+ f-string is still a ``%Y-%m-%d`` string; the f-string merely stringifies it.
31
+ - :attr:`DATETIME` and :attr:`DATE` outrank the numeric kinds, since a column used directly
32
+ where a timestamp is expected must carry a real temporal dtype.
33
+ - :attr:`NUMERIC` outranks :attr:`CATEGORICAL`: rendering a number into an f-string is fine,
34
+ but feeding a category name into arithmetic is not.
35
+ - :attr:`SUBJECT_ID` sits above the plain numeric kinds because MEDS-Extract casts subject ids
36
+ to ``Int64`` with ``strict=True``; that is a stronger promise than "some number".
37
+
38
+ Examples:
39
+ >>> ValueKind.DATETIME_STR > ValueKind.NUMERIC > ValueKind.CATEGORICAL
40
+ True
41
+ >>> max(ValueKind.CATEGORICAL, ValueKind.STRING)
42
+ <ValueKind.CATEGORICAL: 20>
43
+ """
44
+
45
+ UNKNOWN = 0
46
+ STRING = 10
47
+ CATEGORICAL = 20
48
+ BOOLEAN = 30
49
+ IDENTIFIER = 40
50
+ NUMERIC = 50
51
+ INTEGER = 60
52
+ SUBJECT_ID = 70
53
+ DATE = 80
54
+ DATETIME = 90
55
+ DATETIME_STR = 100
56
+
57
+
58
+ #: Kinds whose synthetic values are drawn from a small, reusable vocabulary rather than sampled
59
+ #: independently per row. Categorical columns are what event ``code`` expressions interpolate, and
60
+ #: what ``_metadata`` dictionaries join against, so their value pool has to be finite and shared.
61
+ VOCABULARY_KINDS = frozenset({ValueKind.CATEGORICAL, ValueKind.IDENTIFIER})
62
+
63
+ #: Kinds that must be written as a temporal dtype (or a string parsed into one).
64
+ TEMPORAL_KINDS = frozenset({ValueKind.DATE, ValueKind.DATETIME, ValueKind.DATETIME_STR})
65
+
66
+ #: Kinds that must be written as a number.
67
+ NUMERIC_KINDS = frozenset({ValueKind.NUMERIC, ValueKind.INTEGER, ValueKind.SUBJECT_ID})
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ColumnConstraint:
72
+ """Everything inference has learned about one source column.
73
+
74
+ Attributes:
75
+ kind: The strongest :class:`ValueKind` observed for this column.
76
+ datetime_formats: ``strptime`` format strings this column is parsed with, in first-seen
77
+ order. More than one is normal and meaningful: the 0.7 idiom for a column with mixed
78
+ formats is ``coalesce($x::?"%Y-%m-%d %H:%M:%S", $x::?"%Y-%m-%d")``, and a generator
79
+ that emits only the first format would leave the second branch of that coalesce
80
+ untested. Generation distributes rows across every recorded format.
81
+ required_values: Literal values this column is compared against somewhere in the config
82
+ (e.g. the ``"9"`` in ``$icd_version == "9"``). Seeding these into the value pool is
83
+ what makes a config's conditional branches actually branch, instead of every row
84
+ taking the same path.
85
+ match_patterns: Regexes the column is *tested* against with ``/re/ in $col``. Generation
86
+ emits a mix of matching and non-matching values, so both sides of the resulting
87
+ conditional are exercised.
88
+ extract_patterns: Regexes the column has a value *pulled out of* with
89
+ ``extract group N of /re/ from $col``. Unlike a test, a failed extract yields null and
90
+ usually poisons everything downstream, so every generated value must match these.
91
+ numeric_range: Inferred bounds for a numeric column, where the surrounding arithmetic
92
+ implies a magnitude the value kind alone cannot express — the ``$anchor_year -
93
+ $anchor_age`` idiom, whose difference has to render as a four-digit year.
94
+ observed_kinds: Every kind ever recorded for this column, not just the winning one. Kept
95
+ so contradictory usage (a column read both as a formatted string *and* as a native
96
+ timestamp) can be reported rather than silently resolved.
97
+ min_chars: Minimum character length, accumulated from substring slices (``$col[0:3]``
98
+ needs at least 3 characters) and from ``len_chars($col) > n`` comparisons.
99
+ nullable: Whether the column may contain nulls. Set False for columns whose nullity would
100
+ break the run outright — subject ids and join keys.
101
+ plain_time: True when this column supplies the timestamp of an *ordinary* (non-birth,
102
+ non-death) event. It suppresses :attr:`temporal_role`: a column doing double duty
103
+ cannot be placed on the timeline as a death date, because a death date is null for
104
+ every subject who does not die, which would empty the ordinary event too.
105
+ subject_key: True when this column is the *input* to a ``hash()``/``signed_hash()`` that
106
+ produces the subject id. Such a column is not itself a subject id — it can be any text
107
+ — but it stands in one-to-one for a subject, so its pool must be sized by the requested
108
+ subject count and shared across every table that hashes it.
109
+ temporal_role: ``"birth"`` or ``"death"`` when this column supplies the timestamp of a
110
+ ``MEDS_BIRTH`` / ``MEDS_DEATH`` event. MEDS treats those two as the bounds of a
111
+ subject's timeline, and many downstream tools assume birth precedes every other event,
112
+ so generation places them deliberately rather than sampling them like any other date.
113
+ strict_parse: True when at least one ``strptime`` over this column is strict (``::
114
+ rather than ``::?``). A strict parse drops nothing silently; an unparsable value is a
115
+ hard error, so strictly-parsed columns never receive nulls or off-format values.
116
+ notes: Human-readable provenance, one line per observation. Surfaced by the CLI's
117
+ ``--explain`` output so a user can see *why* a column was typed the way it was.
118
+
119
+ Examples:
120
+ >>> ColumnConstraint(kind=ValueKind.CATEGORICAL).kind.name
121
+ 'CATEGORICAL'
122
+
123
+ Merging keeps the stronger kind and unions the evidence:
124
+
125
+ >>> a = ColumnConstraint(kind=ValueKind.CATEGORICAL, required_values=("9",))
126
+ >>> b = ColumnConstraint(kind=ValueKind.DATETIME_STR, datetime_formats=("%Y-%m-%d",))
127
+ >>> merged = a.merge(b)
128
+ >>> merged.kind.name, merged.datetime_formats, merged.required_values
129
+ ('DATETIME_STR', ('%Y-%m-%d',), ('9',))
130
+
131
+ Merging is commutative, so visit order cannot change the result:
132
+
133
+ >>> a.merge(b) == b.merge(a)
134
+ True
135
+
136
+ Nullability is conjunctive — one usage that forbids nulls forbids them everywhere:
137
+
138
+ >>> ColumnConstraint(nullable=True).merge(ColumnConstraint(nullable=False)).nullable
139
+ False
140
+
141
+ A temporal role survives being merged with a role-less observation of the same column:
142
+
143
+ >>> ColumnConstraint().merge(ColumnConstraint(temporal_role="birth")).temporal_role
144
+ 'birth'
145
+
146
+ Merging remembers every kind it saw, not only the winner, so contradictory usage stays
147
+ visible:
148
+
149
+ >>> a = ColumnConstraint(kind=ValueKind.DATETIME)
150
+ >>> b = ColumnConstraint(kind=ValueKind.DATETIME_STR)
151
+ >>> sorted(k.name for k in a.merge(b).observed_kinds)
152
+ ['DATETIME', 'DATETIME_STR']
153
+ """
154
+
155
+ kind: ValueKind = ValueKind.UNKNOWN
156
+ datetime_formats: tuple[str, ...] = ()
157
+ required_values: tuple[str, ...] = ()
158
+ match_patterns: tuple[str, ...] = ()
159
+ extract_patterns: tuple[str, ...] = ()
160
+ numeric_range: tuple[float, float] | None = None
161
+ observed_kinds: frozenset[ValueKind] = frozenset()
162
+ min_chars: int = 0
163
+ nullable: bool = True
164
+ strict_parse: bool = False
165
+ plain_time: bool = False
166
+ subject_key: bool = False
167
+ temporal_role: str | None = None
168
+ notes: tuple[str, ...] = ()
169
+
170
+ def merge(self, other: ColumnConstraint) -> ColumnConstraint:
171
+ """Combine two independent observations of the same column.
172
+
173
+ Args:
174
+ other: The constraint to fold in.
175
+
176
+ Returns:
177
+ A new constraint holding the stronger kind and the union of all evidence.
178
+
179
+ Examples:
180
+ >>> x = ColumnConstraint(kind=ValueKind.NUMERIC, min_chars=2)
181
+ >>> y = ColumnConstraint(kind=ValueKind.STRING, min_chars=5, strict_parse=True)
182
+ >>> z = x.merge(y)
183
+ >>> z.kind.name, z.min_chars, z.strict_parse
184
+ ('NUMERIC', 5, True)
185
+
186
+ Duplicate evidence is de-duplicated while preserving first-seen order:
187
+
188
+ >>> p = ColumnConstraint(datetime_formats=("%Y", "%Y-%m"))
189
+ >>> q = ColumnConstraint(datetime_formats=("%Y-%m", "%Y-%m-%d"))
190
+ >>> p.merge(q).datetime_formats
191
+ ('%Y', '%Y-%m', '%Y-%m-%d')
192
+ """
193
+ return ColumnConstraint(
194
+ kind=max(self.kind, other.kind),
195
+ datetime_formats=_ordered_union(self.datetime_formats, other.datetime_formats),
196
+ required_values=_ordered_union(self.required_values, other.required_values),
197
+ match_patterns=_ordered_union(self.match_patterns, other.match_patterns),
198
+ extract_patterns=_ordered_union(self.extract_patterns, other.extract_patterns),
199
+ numeric_range=self.numeric_range or other.numeric_range,
200
+ observed_kinds=(self.observed_kinds | other.observed_kinds | {self.kind, other.kind})
201
+ - {ValueKind.UNKNOWN},
202
+ min_chars=max(self.min_chars, other.min_chars),
203
+ nullable=self.nullable and other.nullable,
204
+ strict_parse=self.strict_parse or other.strict_parse,
205
+ plain_time=self.plain_time or other.plain_time,
206
+ subject_key=self.subject_key or other.subject_key,
207
+ temporal_role=self.temporal_role or other.temporal_role,
208
+ notes=_ordered_union(self.notes, other.notes),
209
+ )
210
+
211
+ @property
212
+ def effective_temporal_role(self) -> str | None:
213
+ """The birth/death role to actually generate by, or None.
214
+
215
+ A role is dropped when the same column also supplies an ordinary event's timestamp. HiRID
216
+ does exactly this: ``date_of_death: '$datetime if $_died'`` bottoms out in the same
217
+ ``datetime`` column that its main observation table reads. Honouring the role there would
218
+ fill that column from each subject's death date — null for everyone who survives — and
219
+ silently delete most of the largest table in the dataset.
220
+
221
+ Returns:
222
+ ``"birth"``, ``"death"``, or None.
223
+
224
+ Examples:
225
+ >>> ColumnConstraint(temporal_role="death").effective_temporal_role
226
+ 'death'
227
+ >>> ColumnConstraint(temporal_role="death", plain_time=True).effective_temporal_role is None
228
+ True
229
+ """
230
+ return None if self.plain_time else self.temporal_role
231
+
232
+ @property
233
+ def effective_nullable(self) -> bool:
234
+ """Whether generation may emit nulls for this column.
235
+
236
+ A death timestamp is nullable whatever the parse strictness says: most subjects do not die
237
+ inside the record, and polars' ``strptime`` passes nulls straight through rather than
238
+ failing on them. Treating a strict parse as forbidding nulls would force every synthetic
239
+ subject to have a death date.
240
+
241
+ Returns:
242
+ True if nulls are permitted.
243
+
244
+ Examples:
245
+ >>> ColumnConstraint(nullable=False).effective_nullable
246
+ False
247
+ >>> ColumnConstraint(nullable=False, temporal_role="death").effective_nullable
248
+ True
249
+
250
+ Unless the role was suppressed because the column also times an ordinary event:
251
+
252
+ >>> ColumnConstraint(nullable=False, temporal_role="death", plain_time=True).effective_nullable
253
+ False
254
+ """
255
+ return self.nullable or self.effective_temporal_role == "death"
256
+
257
+ def with_note(self, note: str) -> ColumnConstraint:
258
+ """Return a copy carrying one more provenance line.
259
+
260
+ Args:
261
+ note: The line to append.
262
+
263
+ Returns:
264
+ The updated constraint.
265
+
266
+ Examples:
267
+ >>> ColumnConstraint().with_note("used as time").notes
268
+ ('used as time',)
269
+ """
270
+ return replace(self, notes=_ordered_union(self.notes, (note,)))
271
+
272
+
273
+ def _ordered_union(*groups: tuple[str, ...]) -> tuple[str, ...]:
274
+ """Concatenate tuples, dropping duplicates and preserving first-seen order.
275
+
276
+ ``dict.fromkeys`` is the idiomatic order-preserving de-duplication in Python 3.7+.
277
+
278
+ Args:
279
+ *groups: The tuples to combine.
280
+
281
+ Returns:
282
+ The de-duplicated concatenation.
283
+
284
+ Examples:
285
+ >>> _ordered_union(("a", "b"), ("b", "c"), ())
286
+ ('a', 'b', 'c')
287
+ >>> _ordered_union()
288
+ ()
289
+ """
290
+ return tuple(dict.fromkeys(item for group in groups for item in group))
291
+
292
+
293
+ @dataclass
294
+ class ConstraintSet:
295
+ """A mutable accumulator mapping ``(table_prefix, column)`` to its merged constraint.
296
+
297
+ Inference walks every expression in a MESSY config and calls :meth:`observe` each time it
298
+ reaches a column reference. Because :meth:`ColumnConstraint.merge` is commutative, the walk may
299
+ visit expressions in any order.
300
+
301
+ Examples:
302
+ >>> cs = ConstraintSet()
303
+ >>> cs.observe("labs", "value", ColumnConstraint(kind=ValueKind.NUMERIC))
304
+ >>> cs.observe("labs", "value", ColumnConstraint(kind=ValueKind.CATEGORICAL))
305
+ >>> cs.get("labs", "value").kind.name
306
+ 'NUMERIC'
307
+
308
+ Unobserved columns come back as the bottom element rather than raising:
309
+
310
+ >>> cs.get("labs", "never_seen").kind.name
311
+ 'UNKNOWN'
312
+ >>> sorted(cs.columns_for("labs"))
313
+ ['value']
314
+ """
315
+
316
+ constraints: dict[tuple[str, str], ColumnConstraint] = field(default_factory=dict)
317
+
318
+ def observe(self, prefix: str, column: str, constraint: ColumnConstraint) -> None:
319
+ """Record one observation about ``prefix``/``column``.
320
+
321
+ Args:
322
+ prefix: The source-table prefix the column belongs to.
323
+ column: The column name.
324
+ constraint: What was learned.
325
+ """
326
+ key = (prefix, column)
327
+ existing = self.constraints.get(key)
328
+ self.constraints[key] = constraint if existing is None else existing.merge(constraint)
329
+
330
+ def get(self, prefix: str, column: str) -> ColumnConstraint:
331
+ """Look up the merged constraint for a column.
332
+
333
+ Args:
334
+ prefix: The source-table prefix.
335
+ column: The column name.
336
+
337
+ Returns:
338
+ The merged constraint, or an empty one if the column was never observed.
339
+ """
340
+ return self.constraints.get((prefix, column), ColumnConstraint())
341
+
342
+ def columns_for(self, prefix: str) -> set[str]:
343
+ """Return every column observed for one table prefix.
344
+
345
+ Args:
346
+ prefix: The source-table prefix.
347
+
348
+ Returns:
349
+ The set of observed column names.
350
+ """
351
+ return {col for (p, col) in self.constraints if p == prefix}