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.
- MESSY_synth/__init__.py +58 -0
- MESSY_synth/__main__.py +204 -0
- MESSY_synth/constraints.py +351 -0
- MESSY_synth/generate.py +585 -0
- MESSY_synth/inference.py +925 -0
- MESSY_synth/plan.py +774 -0
- MESSY_synth/regexgen.py +400 -0
- MESSY_synth/smoke.py +342 -0
- MESSY_synth/synth.py +178 -0
- MESSY_synth/validate.py +429 -0
- MESSY_synth/values.py +421 -0
- MESSY_synth/writer.py +280 -0
- messy_synth-0.0.1.dist-info/METADATA +250 -0
- messy_synth-0.0.1.dist-info/RECORD +18 -0
- messy_synth-0.0.1.dist-info/WHEEL +5 -0
- messy_synth-0.0.1.dist-info/entry_points.txt +2 -0
- messy_synth-0.0.1.dist-info/licenses/LICENSE +21 -0
- messy_synth-0.0.1.dist-info/top_level.txt +1 -0
MESSY_synth/__init__.py
ADDED
|
@@ -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
|
+
]
|
MESSY_synth/__main__.py
ADDED
|
@@ -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}
|