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,585 @@
1
+ """Materialize a :class:`~MESSY_synth.plan.DatasetPlan` into in-memory polars frames.
2
+
3
+ This is where the plan's abstract promises become rows. The three that matter, and how they are
4
+ kept:
5
+
6
+ - **One subject universe.** Every column in the ``__subjects__`` pool is filled from the same list
7
+ of integer ids, so events extracted from different files land on the same subjects.
8
+ - **Joins that land.** A column marked ``covers_pool`` *enumerates* its pool positionally rather
9
+ than sampling it, guaranteeing every referenced key exists on the far side. Everything else
10
+ samples, so the data still looks like a many-to-one relationship rather than a lockstep zip.
11
+ - **Coherent time.** Each subject gets a :class:`~MESSY_synth.values.SubjectTimeline` up front, and
12
+ every timestamp for that subject is drawn from inside it — births before the window, deaths
13
+ after, ordinary events within.
14
+
15
+ Generation is deterministic: each column seeds its own RNG from ``(seed, prefix, column)``, so the
16
+ same config and seed always produce byte-identical output, and adding a table to a config does not
17
+ perturb the values in unrelated tables.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import itertools
23
+ import logging
24
+ import random
25
+ from dataclasses import dataclass, field
26
+ from typing import TYPE_CHECKING
27
+
28
+ import polars as pl
29
+
30
+ from .constraints import ColumnConstraint, ValueKind
31
+ from .plan import (
32
+ DEFAULT_ROWS_PER_SUBJECT,
33
+ DEFAULT_VOCAB_SIZE,
34
+ MAX_METADATA_ROWS,
35
+ SUBJECT_KEY_POOL,
36
+ SUBJECT_POOL,
37
+ build_plan,
38
+ )
39
+ from .values import ValueFactory, build_timeline, categorical_pool, format_datetime
40
+
41
+ if TYPE_CHECKING: # pragma: no cover - typing only
42
+ from MEDS_extract.config import MessyConfig
43
+
44
+ from .plan import ColumnPlan, DatasetPlan, TablePlan
45
+ from .values import SubjectTimeline
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ #: Share of rows in a numeric column that take one of the config's compared-against literals.
50
+ REQUIRED_VALUE_SHARE = 0.35
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class GenerationOptions:
55
+ """Knobs controlling the size and shape of the generated dataset.
56
+
57
+ Attributes:
58
+ seed: Master seed. Output is a pure function of (config, options), so a given seed
59
+ reproduces a dataset exactly.
60
+ n_subjects: How many distinct subjects to invent.
61
+ rows_per_subject: Rows per subject in each timed event table.
62
+ vocab_size: Distinct values in each categorical vocabulary.
63
+ null_fraction: Fraction of nullable values emitted as nulls, so the config's ``??``
64
+ coalescing and null-drop paths are actually exercised.
65
+ death_probability: Fraction of subjects given a death timestamp.
66
+ numeric_ranges: Per-column-name numeric bounds overriding the default ``(1, 100)``.
67
+ """
68
+
69
+ seed: int = 0
70
+ n_subjects: int = 20
71
+ rows_per_subject: int = DEFAULT_ROWS_PER_SUBJECT
72
+ vocab_size: int = DEFAULT_VOCAB_SIZE
73
+ null_fraction: float = 0.05
74
+ death_probability: float = 0.3
75
+ numeric_ranges: dict[str, tuple[float, float]] = field(default_factory=dict)
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class GeneratedDataset:
80
+ """The generated frames plus the plan that produced them.
81
+
82
+ Attributes:
83
+ plan: The plan this dataset was generated from, retained so callers can explain or verify
84
+ the output without recomputing it.
85
+ frames: One frame per source file, keyed by table prefix.
86
+ timelines: The invented per-subject timelines, keyed by subject id.
87
+ truncated_metadata: Metadata prefixes whose key combinations exceeded
88
+ :data:`~MESSY_synth.plan.MAX_METADATA_ROWS`, mapped to how many were dropped. Codes
89
+ built from a dropped combination have no dictionary row, so this is surfaced as a
90
+ finding rather than left to a log line.
91
+ """
92
+
93
+ plan: DatasetPlan
94
+ frames: dict[str, pl.DataFrame]
95
+ timelines: dict[int, SubjectTimeline]
96
+ truncated_metadata: dict[str, int] = field(default_factory=dict)
97
+
98
+ def summary(self) -> pl.DataFrame:
99
+ """Return a one-row-per-file overview of what was generated.
100
+
101
+ Returns:
102
+ A frame with ``prefix``, ``rows``, ``columns``, and ``kind`` columns.
103
+ """
104
+ return pl.DataFrame(
105
+ {
106
+ "prefix": [t.prefix for t in self.plan.tables],
107
+ "rows": [self.frames[t.prefix].height for t in self.plan.tables],
108
+ "columns": [self.frames[t.prefix].width for t in self.plan.tables],
109
+ "kind": [t.kind for t in self.plan.tables],
110
+ }
111
+ )
112
+
113
+
114
+ def generate(cfg: MessyConfig, options: GenerationOptions | None = None) -> GeneratedDataset:
115
+ """Generate a complete synthetic source dataset for one MESSY config.
116
+
117
+ Args:
118
+ cfg: The parsed MESSY config.
119
+ options: Size and shape knobs; defaults to :class:`GenerationOptions`.
120
+
121
+ Returns:
122
+ The :class:`GeneratedDataset`.
123
+
124
+ Examples:
125
+ >>> from MEDS_extract.config import MessyConfig
126
+ >>> cfg = MessyConfig.parse({
127
+ ... "etl": {"dataset_name": "Demo"},
128
+ ... "_defaults": {"subject_id": "$patient_id"},
129
+ ... "patients": {
130
+ ... "dob": {"code": "MEDS_BIRTH", "time": '$dob::"%Y-%m-%d"'},
131
+ ... "sex": {"code": 'f"SEX//{$sex}"', "time": None},
132
+ ... },
133
+ ... "labs": {
134
+ ... "lab": {
135
+ ... "code": 'f"LAB//{$itemid}"',
136
+ ... "time": '$charttime::"%Y-%m-%d %H:%M:%S"',
137
+ ... "numeric_value": "$value",
138
+ ... "_metadata": {"d_labitems": {"itemid": "$itemid", "description": "$label"}},
139
+ ... },
140
+ ... },
141
+ ... })
142
+ >>> ds = generate(cfg, GenerationOptions(seed=0, n_subjects=4, rows_per_subject=2))
143
+ >>> ds.summary()
144
+ shape: (3, 4)
145
+ ┌────────────┬──────┬─────────┬───────────────┐
146
+ │ prefix ┆ rows ┆ columns ┆ kind │
147
+ │ --- ┆ --- ┆ --- ┆ --- │
148
+ │ str ┆ i64 ┆ i64 ┆ str │
149
+ ╞════════════╪══════╪═════════╪═══════════════╡
150
+ │ d_labitems ┆ 8 ┆ 2 ┆ metadata │
151
+ │ labs ┆ 8 ┆ 4 ┆ events │
152
+ │ patients ┆ 4 ┆ 3 ┆ subject-level │
153
+ └────────────┴──────┴─────────┴───────────────┘
154
+
155
+ ``patients`` carries a static event, so it is one row per subject; the birth column is
156
+ rendered in exactly the format the config parses it with, and the occasional null in a
157
+ code component is deliberate — in 0.7 a null component drops the row, and that path
158
+ deserves to be exercised too:
159
+
160
+ >>> ds.frames["patients"].sort("patient_id")
161
+ shape: (4, 3)
162
+ ┌────────────┬────────────┬───────────────┐
163
+ │ patient_id ┆ dob ┆ sex │
164
+ │ --- ┆ --- ┆ --- │
165
+ │ i64 ┆ str ┆ str │
166
+ ╞════════════╪════════════╪═══════════════╡
167
+ │ 1 ┆ 1969-09-28 ┆ null │
168
+ │ 2 ┆ 1959-10-06 ┆ SYNTH_SEX_004 │
169
+ │ 3 ┆ 1940-10-26 ┆ SYNTH_SEX_001 │
170
+ │ 4 ┆ 1951-11-27 ┆ SYNTH_SEX_003 │
171
+ └────────────┴────────────┴───────────────┘
172
+
173
+ Every value is visibly synthetic; nothing here resembles a real vocabulary.
174
+
175
+ The metadata dictionary covers the event table's vocabulary exactly, which is what makes
176
+ the code-description join land instead of silently matching nothing:
177
+
178
+ >>> set(ds.frames["labs"]["itemid"]) <= set(ds.frames["d_labitems"]["itemid"])
179
+ True
180
+
181
+ Generation is deterministic given a seed:
182
+
183
+ >>> other = generate(cfg, GenerationOptions(seed=0, n_subjects=4, rows_per_subject=2))
184
+ >>> other.frames["labs"].equals(ds.frames["labs"])
185
+ True
186
+ """
187
+ options = options or GenerationOptions()
188
+ plan = build_plan(
189
+ cfg,
190
+ n_subjects=options.n_subjects,
191
+ rows_per_subject=options.rows_per_subject,
192
+ vocab_size=options.vocab_size,
193
+ )
194
+
195
+ pools = _materialize_pools(plan, options)
196
+ subject_ids: list[int] = pools[SUBJECT_POOL]
197
+ timelines = {
198
+ sid: build_timeline(sid, random.Random(f"{options.seed}:timeline:{sid}"), options.death_probability)
199
+ for sid in subject_ids
200
+ }
201
+
202
+ joins = {t.input_prefix: t.join for t in cfg.event_tables if t.join is not None}
203
+ frames: dict[str, pl.DataFrame] = {}
204
+ truncated: dict[str, int] = {}
205
+ for table in _generation_order(plan, joins):
206
+ frame = _generate_table(table, plan, pools, timelines, subject_ids, options, truncated)
207
+ join = joins.get(table.prefix)
208
+ if join is not None and join.input_prefix in frames:
209
+ frame = _adopt_join_keys(frame, table, join, frames[join.input_prefix], options)
210
+ frames[table.prefix] = frame
211
+ return GeneratedDataset(plan=plan, frames=frames, timelines=timelines, truncated_metadata=truncated)
212
+
213
+
214
+ def _generation_order(plan: DatasetPlan, joins: dict) -> list[TablePlan]:
215
+ """Order tables so every join target is built before the table that joins to it.
216
+
217
+ Join keys are not invented independently on the two sides — the referencing table copies real
218
+ key tuples out of the target's finished frame — so the target has to exist first.
219
+
220
+ Args:
221
+ plan: The dataset plan.
222
+ joins: Table prefix to its :class:`~MEDS_extract.config.JoinConfig`.
223
+
224
+ Returns:
225
+ The table plans in dependency order. A cycle (which MEDS-Extract rejects for self-joins,
226
+ but could in principle span tables) degrades to the plan's own order rather than hanging.
227
+ """
228
+ by_prefix = {t.prefix: t for t in plan.tables}
229
+ ordered: list[TablePlan] = []
230
+ placed: set[str] = set()
231
+
232
+ def visit(prefix: str, seen: frozenset[str]) -> None:
233
+ if prefix in placed or prefix in seen or prefix not in by_prefix:
234
+ return
235
+ join = joins.get(prefix)
236
+ if join is not None:
237
+ visit(join.input_prefix, seen | {prefix})
238
+ if prefix not in placed:
239
+ placed.add(prefix)
240
+ ordered.append(by_prefix[prefix])
241
+
242
+ for table in plan.tables:
243
+ visit(table.prefix, frozenset())
244
+ return ordered
245
+
246
+
247
+ def _adopt_join_keys(
248
+ frame: pl.DataFrame,
249
+ table: TablePlan,
250
+ join,
251
+ target: pl.DataFrame,
252
+ options: GenerationOptions,
253
+ ) -> pl.DataFrame:
254
+ """Replace a table's join-key columns with real key tuples drawn from the target's frame.
255
+
256
+ Generating the two sides independently is not good enough for a composite key: each column
257
+ might individually come from the right pool while the *combination* exists nowhere on the far
258
+ side, so almost every row dangles and is silently dropped. Copying whole tuples from the target
259
+ makes every left key resolve by construction, for composite and single keys alike.
260
+
261
+ The subject column is exempt. It already agrees across tables by drawing from the shared subject
262
+ universe, and overwriting it would break the round-robin assignment that gives every subject a
263
+ row — and, in a subject-level table, would put two rows on one subject.
264
+
265
+ Args:
266
+ frame: The freshly generated frame.
267
+ table: Its plan.
268
+ join: The table's join config.
269
+ target: The already-generated frame of the join target.
270
+ options: The generation options, for the seed.
271
+
272
+ Returns:
273
+ The frame with its join keys adopted from the target.
274
+ """
275
+ pairs = [
276
+ (left, right)
277
+ for left, right in zip(join.left_on, join.right_on, strict=True)
278
+ if left != table.subject_column and left in frame.columns and right in target.columns
279
+ ]
280
+ if not pairs or target.height == 0:
281
+ return frame
282
+
283
+ rng = random.Random(f"{options.seed}:joinkeys:{table.prefix}")
284
+ picks = [rng.randrange(target.height) for _ in range(frame.height)]
285
+ return frame.with_columns(
286
+ *(target[right].gather(picks).alias(left) for left, right in pairs),
287
+ )
288
+
289
+
290
+ def _materialize_pools(plan: DatasetPlan, options: GenerationOptions) -> dict[str, list]:
291
+ """Turn each planned pool into its concrete list of values.
292
+
293
+ Args:
294
+ plan: The dataset plan.
295
+ options: The generation options.
296
+
297
+ Returns:
298
+ A mapping of pool id to values.
299
+ """
300
+ pools: dict[str, list] = {SUBJECT_POOL: list(range(1, options.n_subjects + 1))}
301
+ for pool_id, pool in plan.pools.items():
302
+ if pool_id == SUBJECT_POOL:
303
+ continue
304
+ # Name the pool after its first member column so tokens are self-describing.
305
+ column = pool.members[0][1] if pool.members else pool_id
306
+ pools[pool_id] = categorical_pool(
307
+ column, pool.constraint, pool.size, random.Random(f"{options.seed}:pool:{pool_id}")
308
+ )
309
+ return pools
310
+
311
+
312
+ def _generate_table(
313
+ table: TablePlan,
314
+ plan: DatasetPlan,
315
+ pools: dict[str, list],
316
+ timelines: dict[int, SubjectTimeline],
317
+ subject_ids: list[int],
318
+ options: GenerationOptions,
319
+ truncated: dict[str, int],
320
+ ) -> pl.DataFrame:
321
+ """Generate one file's frame.
322
+
323
+ Args:
324
+ table: The table plan.
325
+ plan: The dataset plan (for pool metadata).
326
+ pools: Materialized pool values.
327
+ timelines: Per-subject timelines.
328
+ subject_ids: The subject universe.
329
+ options: The generation options.
330
+ truncated: Accumulator recording metadata tables whose key product hit the row cap.
331
+
332
+ Returns:
333
+ The generated frame.
334
+ """
335
+ if table.is_metadata:
336
+ return _generate_metadata_table(table, pools, options, truncated)
337
+
338
+ # The plan sizes tables from each pool's *requested* size, but a materialized pool can end up
339
+ # longer — required values, coalesce fallbacks and regex-derived members are all added on top.
340
+ # A covering column must enumerate whatever the pool actually holds, so the row count is raised
341
+ # to match; otherwise the tail of the pool never appears and every row referencing it dangles.
342
+ n_rows = max(
343
+ [table.n_rows, *(len(pools[c.pool_id]) for c in table.columns if c.covers_pool and c.pool_id)]
344
+ )
345
+ # Round-robin rather than random assignment, so every subject is represented in every table
346
+ # instead of a few subjects hogging the rows by chance.
347
+ row_subjects = [subject_ids[i % len(subject_ids)] for i in range(n_rows)] if subject_ids else []
348
+
349
+ data: dict[str, list] = {}
350
+ for column in table.columns:
351
+ data[column.name] = _generate_column(
352
+ column, table, plan, pools, timelines, row_subjects, n_rows, options
353
+ )
354
+ return pl.DataFrame(data, strict=False)
355
+
356
+
357
+ def _generate_metadata_table(
358
+ table: TablePlan,
359
+ pools: dict[str, list],
360
+ options: GenerationOptions,
361
+ truncated: dict[str, int],
362
+ ) -> pl.DataFrame:
363
+ """Generate a ``_metadata`` dictionary file.
364
+
365
+ Key columns take the Cartesian product of their pools so that every code the event tables can
366
+ emit has a matching dictionary row. Anything else is descriptive filler.
367
+
368
+ Args:
369
+ table: The table plan.
370
+ pools: Materialized pool values.
371
+ options: The generation options.
372
+ truncated: Accumulator recording how many key combinations were dropped, if any.
373
+
374
+ Returns:
375
+ The generated frame.
376
+ """
377
+ key_columns = [c for c in table.columns if c.covers_pool and c.pool_id]
378
+ other_columns = [c for c in table.columns if c not in key_columns]
379
+
380
+ if key_columns:
381
+ # Every combination, not `table.n_rows` of them: the plan's estimate is based on requested
382
+ # pool sizes, and truncating to it would leave part of the event vocabulary without a
383
+ # dictionary entry — which shows up only as silently null descriptions in codes.parquet.
384
+ combos = list(itertools.product(*[pools[c.pool_id] for c in key_columns]))
385
+ if len(combos) > MAX_METADATA_ROWS:
386
+ # Say so rather than truncating quietly: the dropped combinations become codes with no
387
+ # description, which is indistinguishable from a broken metadata join unless flagged.
388
+ truncated[table.prefix] = len(combos) - MAX_METADATA_ROWS
389
+ logger.warning(
390
+ f"{table.prefix}: {len(combos)} key combinations exceeds the {MAX_METADATA_ROWS}-row "
391
+ f"cap; dropping {len(combos) - MAX_METADATA_ROWS}. Codes built from the dropped "
392
+ f"combinations will have no description. Lower --vocab-size to fit."
393
+ )
394
+ combos = combos[:MAX_METADATA_ROWS]
395
+ else:
396
+ combos = [()] * table.n_rows
397
+
398
+ data: dict[str, list] = {}
399
+ for i, column in enumerate(key_columns):
400
+ data[column.name] = [combo[i] for combo in combos]
401
+ for column in other_columns:
402
+ factory = ValueFactory(
403
+ random.Random(f"{options.seed}:{table.prefix}:{column.name}"),
404
+ null_fraction=0.0,
405
+ numeric_ranges=options.numeric_ranges,
406
+ )
407
+ # Metadata files are read with `infer_schema=False`, so every column is text regardless of
408
+ # what it looks like. Emitting strings here keeps generation honest about that.
409
+ data[column.name] = [factory.text(column.name) for _ in combos]
410
+ return pl.DataFrame(data, strict=False)
411
+
412
+
413
+ def _generate_column(
414
+ column: ColumnPlan,
415
+ table: TablePlan,
416
+ plan: DatasetPlan,
417
+ pools: dict[str, list],
418
+ timelines: dict[int, SubjectTimeline],
419
+ row_subjects: list[int],
420
+ n_rows: int,
421
+ options: GenerationOptions,
422
+ ) -> list:
423
+ """Generate the values for one column.
424
+
425
+ Args:
426
+ column: The column plan.
427
+ table: The owning table plan.
428
+ plan: The dataset plan.
429
+ pools: Materialized pool values.
430
+ timelines: Per-subject timelines.
431
+ row_subjects: The subject id assigned to each row.
432
+ n_rows: The number of rows.
433
+ options: The generation options.
434
+
435
+ Returns:
436
+ The column's values.
437
+ """
438
+ del plan
439
+ rng = random.Random(f"{options.seed}:{table.prefix}:{column.name}")
440
+ factory = ValueFactory(rng, null_fraction=options.null_fraction, numeric_ranges=options.numeric_ranges)
441
+ constraint = column.constraint
442
+ kind = constraint.kind
443
+
444
+ if column.pool_id == SUBJECT_POOL:
445
+ return list(row_subjects)
446
+
447
+ if column.pool_id == SUBJECT_KEY_POOL:
448
+ # This column is hashed into the subject id, so it is a function *of the subject*, not a
449
+ # free draw. Indexing by the row's subject keeps one key per subject (sampling would give
450
+ # some subjects two keys and others none) and keeps the key consistent with the timeline
451
+ # already chosen for that subject.
452
+ pool = pools[column.pool_id]
453
+ return [pool[(subject - 1) % len(pool)] for subject in row_subjects]
454
+
455
+ if column.unique_in_table:
456
+ # One distinct value per row: a repeated key on the target side of a plain left join fans
457
+ # the join out, turning one referencing row into many.
458
+ return [factory.token(column.name, i, constraint.min_chars) for i in range(n_rows)]
459
+
460
+ if column.pool_id is not None:
461
+ pool = pools[column.pool_id]
462
+ if column.covers_pool:
463
+ # Enumerate rather than sample: a key value missing from the target table would leave
464
+ # every row referencing it unmatched.
465
+ return [pool[i % len(pool)] for i in range(n_rows)]
466
+ return [factory.maybe_null(rng.choice(pool), constraint.nullable) for _ in range(n_rows)]
467
+
468
+ if kind in (ValueKind.DATETIME, ValueKind.DATE, ValueKind.DATETIME_STR):
469
+ return _generate_temporal(column, timelines, row_subjects, n_rows, factory, rng)
470
+
471
+ if kind in (ValueKind.NUMERIC, ValueKind.INTEGER, ValueKind.IDENTIFIER):
472
+ integral = kind is not ValueKind.NUMERIC
473
+ # A literal the config compares against (`$admissioncount == 1`) has to actually occur, or
474
+ # the branch it gates never fires and the event yields nothing. About a third of rows take
475
+ # a required value, so both sides of the comparison are represented.
476
+ required = [n for n in (_as_number(v, integral) for v in constraint.required_values) if n is not None]
477
+ return [
478
+ factory.maybe_null(
479
+ required[i % len(required)]
480
+ if required and rng.random() < REQUIRED_VALUE_SHARE
481
+ else factory.numeric(column.name, integral=integral, inferred_range=constraint.numeric_range),
482
+ constraint.nullable,
483
+ )
484
+ for i in range(n_rows)
485
+ ]
486
+ if kind is ValueKind.BOOLEAN:
487
+ return [factory.maybe_null(rng.choice([True, False]), constraint.nullable) for _ in range(n_rows)]
488
+ if kind is ValueKind.STRING and not _has_shape_evidence(constraint):
489
+ return [factory.maybe_null(factory.text(column.name), constraint.nullable) for _ in range(n_rows)]
490
+
491
+ # Everything else becomes a small token vocabulary. UNKNOWN lands here because a column we
492
+ # could not reason about is safest as an opaque string: it survives CSV inference unchanged and
493
+ # can be interpolated into any code. A STRING column reaches here only when the config pins its
494
+ # shape — a regex it must satisfy, or a literal it is compared against — because free-form
495
+ # placeholder text would fail those.
496
+ vocab = categorical_pool(column.name, constraint, options.vocab_size, rng)
497
+ return [factory.maybe_null(rng.choice(vocab), constraint.nullable) for _ in range(n_rows)]
498
+
499
+
500
+ def _has_shape_evidence(constraint: ColumnConstraint) -> bool:
501
+ """Return whether the config pins a column's textual shape.
502
+
503
+ Args:
504
+ constraint: The column's constraint.
505
+
506
+ Returns:
507
+ True if a regex or a compared-against literal constrains what the text must look like.
508
+
509
+ Examples:
510
+ >>> _has_shape_evidence(ColumnConstraint())
511
+ False
512
+ >>> _has_shape_evidence(ColumnConstraint(extract_patterns=("2003|2010",)))
513
+ True
514
+ """
515
+ return bool(constraint.extract_patterns or constraint.match_patterns or constraint.required_values)
516
+
517
+
518
+ def _as_number(text: str, integral: bool) -> float | int | None:
519
+ """Parse a recorded literal back into a number, or None if it is not numeric.
520
+
521
+ Required values are recorded as text because most of them are categorical. When the column
522
+ turns out to be numeric they have to be converted back.
523
+
524
+ Args:
525
+ text: The recorded literal.
526
+ integral: Whether an integer is wanted.
527
+
528
+ Returns:
529
+ The number, or None if ``text`` is not numeric.
530
+
531
+ Examples:
532
+ >>> _as_number("1", True), _as_number("1.5", False), _as_number("alive", False)
533
+ (1, 1.5, None)
534
+ """
535
+ try:
536
+ return int(float(text)) if integral else float(text)
537
+ except (TypeError, ValueError):
538
+ return None
539
+
540
+
541
+ def _generate_temporal(
542
+ column: ColumnPlan,
543
+ timelines: dict[int, SubjectTimeline],
544
+ row_subjects: list[int],
545
+ n_rows: int,
546
+ factory: ValueFactory,
547
+ rng: random.Random,
548
+ ) -> list:
549
+ """Generate a timestamp column, honoring birth/death roles and the recorded string formats.
550
+
551
+ Args:
552
+ column: The column plan.
553
+ timelines: Per-subject timelines.
554
+ row_subjects: The subject id assigned to each row.
555
+ n_rows: The number of rows.
556
+ factory: The value factory (for null injection).
557
+ rng: The random source.
558
+
559
+ Returns:
560
+ The column's values — formatted strings when the config parses this column with
561
+ ``strptime``, real datetimes otherwise.
562
+ """
563
+ constraint = column.constraint
564
+ as_string = constraint.kind is ValueKind.DATETIME_STR
565
+ out: list = []
566
+ for i in range(n_rows):
567
+ subject = row_subjects[i] if i < len(row_subjects) else None
568
+ timeline = timelines.get(subject) if subject is not None else None
569
+ if timeline is None:
570
+ timeline = build_timeline(0, random.Random(f"anon:{i}"))
571
+
572
+ role = constraint.effective_temporal_role
573
+ if role == "birth":
574
+ value = timeline.birth
575
+ elif role == "death":
576
+ value = timeline.death
577
+ else:
578
+ value = timeline.sample(rng)
579
+
580
+ if value is None:
581
+ out.append(None)
582
+ continue
583
+ rendered = format_datetime(value, constraint, i) if as_string else value
584
+ out.append(factory.maybe_null(rendered, constraint.effective_nullable))
585
+ return out