terrine 0.1.0.dev0__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.
Files changed (144) hide show
  1. terrine/__init__.py +120 -0
  2. terrine/_time.py +21 -0
  3. terrine/adapters/__init__.py +3 -0
  4. terrine/adapters/increment.py +365 -0
  5. terrine/adapters/opentelemetry.py +63 -0
  6. terrine/agent/__init__.py +6 -0
  7. terrine/agent/definitions.py +143 -0
  8. terrine/agent/design_schema.py +220 -0
  9. terrine/agent/policy.py +144 -0
  10. terrine/agent/schemas.py +285 -0
  11. terrine/agent/tools.py +956 -0
  12. terrine/backends/__init__.py +3 -0
  13. terrine/backends/capabilities.py +117 -0
  14. terrine/backends/dialect.py +55 -0
  15. terrine/backends/extrema.py +40 -0
  16. terrine/backends/joins.py +75 -0
  17. terrine/backends/numeric.py +657 -0
  18. terrine/backends/sequences.py +40 -0
  19. terrine/backends/timestamps.py +421 -0
  20. terrine/compile/__init__.py +3 -0
  21. terrine/compile/_asof.py +165 -0
  22. terrine/compile/_predicates.py +110 -0
  23. terrine/compile/anchors.py +492 -0
  24. terrine/compile/columns.py +1353 -0
  25. terrine/compile/cost.py +110 -0
  26. terrine/compile/dispatch.py +148 -0
  27. terrine/compile/layers.py +242 -0
  28. terrine/compile/materialize.py +91 -0
  29. terrine/compile/provenance.py +120 -0
  30. terrine/compile/ranges.py +91 -0
  31. terrine/compile/retention.py +127 -0
  32. terrine/compile/shapes/__init__.py +3 -0
  33. terrine/compile/shapes/_observations.py +502 -0
  34. terrine/compile/shapes/calendar_comparison.py +370 -0
  35. terrine/compile/shapes/cohorts.py +285 -0
  36. terrine/compile/shapes/event_panel.py +46 -0
  37. terrine/compile/shapes/event_sequence.py +498 -0
  38. terrine/compile/shapes/life_table.py +244 -0
  39. terrine/compile/shapes/ltv.py +287 -0
  40. terrine/compile/shapes/matched_t0.py +75 -0
  41. terrine/compile/shapes/sequential.py +320 -0
  42. terrine/compile/shapes/state_panel.py +460 -0
  43. terrine/compile/shapes/time_to_event.py +521 -0
  44. terrine/data.py +1311 -0
  45. terrine/dataset/__init__.py +5 -0
  46. terrine/dataset/export.py +463 -0
  47. terrine/dataset/manifest.py +254 -0
  48. terrine/dataset/reference.py +582 -0
  49. terrine/dataset/splits.py +373 -0
  50. terrine/dataset/training.py +114 -0
  51. terrine/dataset/transforms.py +182 -0
  52. terrine/errors.py +98 -0
  53. terrine/execution/__init__.py +5 -0
  54. terrine/execution/_bigquery.py +318 -0
  55. terrine/execution/_metadata.py +47 -0
  56. terrine/execution/_native.py +574 -0
  57. terrine/execution/_postgres.py +468 -0
  58. terrine/execution/_publish.py +299 -0
  59. terrine/execution/_snowflake.py +684 -0
  60. terrine/execution/backend.py +349 -0
  61. terrine/execution/gates.py +90 -0
  62. terrine/execution/inputs.py +923 -0
  63. terrine/export/__init__.py +31 -0
  64. terrine/export/diff_diff.py +177 -0
  65. terrine/export/lifelines.py +219 -0
  66. terrine/export/preflight.py +243 -0
  67. terrine/export/surrogate.py +168 -0
  68. terrine/frame.py +1042 -0
  69. terrine/model.py +397 -0
  70. terrine/orchestration/__init__.py +3 -0
  71. terrine/orchestration/airflow.py +115 -0
  72. terrine/orchestration/dagster.py +103 -0
  73. terrine/py.typed +0 -0
  74. terrine/recipes.py +295 -0
  75. terrine/runtime/__init__.py +70 -0
  76. terrine/runtime/api.py +803 -0
  77. terrine/runtime/arrow.py +98 -0
  78. terrine/runtime/backends/base.py +70 -0
  79. terrine/runtime/backends/duckdb.py +645 -0
  80. terrine/runtime/changes.py +71 -0
  81. terrine/runtime/cli.py +108 -0
  82. terrine/runtime/control.py +355 -0
  83. terrine/runtime/cost.py +211 -0
  84. terrine/runtime/events.py +194 -0
  85. terrine/runtime/execute.py +932 -0
  86. terrine/runtime/gc.py +43 -0
  87. terrine/runtime/graph.py +200 -0
  88. terrine/runtime/layers.py +61 -0
  89. terrine/runtime/leases.py +165 -0
  90. terrine/runtime/materialize.py +53 -0
  91. terrine/runtime/owner.py +57 -0
  92. terrine/runtime/planner.py +441 -0
  93. terrine/runtime/planning.py +394 -0
  94. terrine/runtime/saved_plan.py +337 -0
  95. terrine/runtime/snapshot.py +541 -0
  96. terrine/runtime/state.py +585 -0
  97. terrine/runtime/status.py +360 -0
  98. terrine/runtime/synchronization.py +15 -0
  99. terrine/semantic/__init__.py +3 -0
  100. terrine/semantic/bind.py +309 -0
  101. terrine/semantic/io.py +116 -0
  102. terrine/semantic/models.py +342 -0
  103. terrine/semantic/rules.py +113 -0
  104. terrine/semantic/sql.py +185 -0
  105. terrine/semantic/time.py +42 -0
  106. terrine/simulate/__init__.py +24 -0
  107. terrine/simulate/baseline.yaml +57 -0
  108. terrine/simulate/generate.py +182 -0
  109. terrine/simulate/scale.py +234 -0
  110. terrine/simulate/world.py +75 -0
  111. terrine/spec/__init__.py +96 -0
  112. terrine/spec/anchors.py +107 -0
  113. terrine/spec/dataset.py +260 -0
  114. terrine/spec/design.py +361 -0
  115. terrine/spec/expressions.py +440 -0
  116. terrine/spec/io.py +829 -0
  117. terrine/spec/panels.py +359 -0
  118. terrine/spec/sequence.py +165 -0
  119. terrine/spec/state_panel.py +246 -0
  120. terrine/spec/trajectory.py +144 -0
  121. terrine/spec/transforms.py +260 -0
  122. terrine/spec/validate.py +758 -0
  123. terrine/temporal/__init__.py +3 -0
  124. terrine/temporal/aggregate.py +688 -0
  125. terrine/temporal/attributes.py +389 -0
  126. terrine/temporal/daily.py +481 -0
  127. terrine/temporal/first.py +239 -0
  128. terrine/temporal/lookup.py +194 -0
  129. terrine/temporal/nonadditive.py +466 -0
  130. terrine/temporal/prefix.py +104 -0
  131. terrine/temporal/ranges.py +122 -0
  132. terrine/temporal/readiness.py +41 -0
  133. terrine/temporal/retention.py +225 -0
  134. terrine/temporal/sequence.py +900 -0
  135. terrine/temporal/sessionize.py +135 -0
  136. terrine/temporal/sketches.py +329 -0
  137. terrine/windows.py +54 -0
  138. terrine-0.1.0.dev0.dist-info/METADATA +164 -0
  139. terrine-0.1.0.dev0.dist-info/RECORD +144 -0
  140. terrine-0.1.0.dev0.dist-info/WHEEL +4 -0
  141. terrine-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  142. terrine-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  143. terrine-0.1.0.dev0.dist-info/licenses/NOTICE +4 -0
  144. terrine-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.txt +219 -0
terrine/__init__.py ADDED
@@ -0,0 +1,120 @@
1
+ """Typed, semantic-layer dataset construction."""
2
+
3
+ from importlib import import_module
4
+ from typing import TYPE_CHECKING
5
+
6
+ from terrine.errors import TerrineError
7
+ from terrine.windows import Window
8
+
9
+ if TYPE_CHECKING:
10
+ from terrine import recipes as recipes
11
+ from terrine.compile.cost import preview
12
+ from terrine.data import Budget, Coverage, Events, UnitSource
13
+ from terrine.dataset.reference import Dataset
14
+ from terrine.frame import Frame
15
+ from terrine.model import Model
16
+ from terrine.spec import (
17
+ AnchorRange,
18
+ BinnedMetricState,
19
+ CohortGrouping,
20
+ CohortSpec,
21
+ DatasetDefinition,
22
+ Design,
23
+ EventAnchor,
24
+ EventSequence,
25
+ EventTime,
26
+ FixedDate,
27
+ FunnelSpec,
28
+ LifeTable,
29
+ Schedule,
30
+ SessionSpec,
31
+ SketchRequest,
32
+ StateObservations,
33
+ StatePanel,
34
+ TimeToEvent,
35
+ Trajectory,
36
+ Treatment,
37
+ agg,
38
+ all_of,
39
+ approx_distinct,
40
+ approx_quantile,
41
+ asof,
42
+ eq,
43
+ event_time,
44
+ from_yaml,
45
+ gt,
46
+ is_null,
47
+ quantile,
48
+ retention,
49
+ to_yaml,
50
+ )
51
+
52
+ __all__ = [
53
+ "Dataset",
54
+ "DatasetDefinition",
55
+ "AnchorRange",
56
+ "Budget",
57
+ "EventSequence",
58
+ "EventTime",
59
+ "FunnelSpec",
60
+ "SessionSpec",
61
+ "Coverage",
62
+ "Events",
63
+ "Design",
64
+ "EventAnchor",
65
+ "FixedDate",
66
+ "Frame",
67
+ "retention",
68
+ "Model",
69
+ "Schedule",
70
+ "SketchRequest",
71
+ "Treatment",
72
+ "UnitSource",
73
+ "Window",
74
+ "TerrineError",
75
+ "agg",
76
+ "all_of",
77
+ "approx_quantile",
78
+ "approx_distinct",
79
+ "asof",
80
+ "eq",
81
+ "from_yaml",
82
+ "event_time",
83
+ "gt",
84
+ "is_null",
85
+ "preview",
86
+ "BinnedMetricState",
87
+ "StateObservations",
88
+ "StatePanel",
89
+ "CohortGrouping",
90
+ "CohortSpec",
91
+ "LifeTable",
92
+ "Trajectory",
93
+ "TimeToEvent",
94
+ "to_yaml",
95
+ "quantile",
96
+ "recipes",
97
+ ]
98
+
99
+ _LAZY_MODULES = {
100
+ "Dataset": "terrine.dataset.reference",
101
+ "Budget": "terrine.data",
102
+ "Frame": "terrine.frame",
103
+ "preview": "terrine.compile.cost",
104
+ "Coverage": "terrine.data",
105
+ "Events": "terrine.data",
106
+ "Model": "terrine.model",
107
+ "UnitSource": "terrine.data",
108
+ }
109
+
110
+
111
+ def __getattr__(name: str):
112
+ if name not in __all__:
113
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
114
+ value = (
115
+ import_module("terrine.recipes")
116
+ if name == "recipes"
117
+ else getattr(import_module(_LAZY_MODULES.get(name, "terrine.spec")), name)
118
+ )
119
+ globals()[name] = value
120
+ return value
terrine/_time.py ADDED
@@ -0,0 +1,21 @@
1
+ """Exact microsecond UTC instants for portable definitions."""
2
+
3
+ import re
4
+ from datetime import UTC, datetime
5
+
6
+ _FRACTION = re.compile(r"[.,]([0-9]+)")
7
+
8
+
9
+ def utc_instant(value: datetime | str) -> datetime:
10
+ """Normalize an aware instant without silently discarding submicroseconds."""
11
+ if isinstance(value, str):
12
+ for match in _FRACTION.finditer(value):
13
+ if match.group(1)[6:].strip("0"):
14
+ raise ValueError("instant must be exactly representable at microsecond precision")
15
+ value = datetime.fromisoformat(value)
16
+ if value.tzinfo is None or value.utcoffset() is None:
17
+ raise ValueError("instant must be timezone-aware")
18
+ value = value.astimezone(UTC)
19
+ if type(value) is not datetime:
20
+ return utc_instant(value.isoformat())
21
+ return value
@@ -0,0 +1,3 @@
1
+ """Optional input-format adapters."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,365 @@
1
+ """Optional bridge from published increment definitions into native Catalogs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from importlib import import_module
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ import yaml
11
+ from yaml.constructor import ConstructorError
12
+ from yaml.resolver import BaseResolver
13
+
14
+ from terrine.errors import RefusalSpec
15
+ from terrine.semantic.models import (
16
+ Catalog,
17
+ Dimension,
18
+ Fact,
19
+ Filter,
20
+ Measure,
21
+ Metric,
22
+ Property,
23
+ Source,
24
+ SqlQuery,
25
+ Validity,
26
+ )
27
+ from terrine.semantic.rules import UNSUPPORTED_TEMPORAL_KIND
28
+ from terrine.semantic.sql import admit_read_query
29
+
30
+ __all__ = ["to_catalog"]
31
+
32
+ _MISSING = RefusalSpec(
33
+ "terrine.increment.missing_extra",
34
+ "The increment compatibility extra is required; install terrine[increment].",
35
+ )
36
+ _DUPLICATE_YAML_KEY = RefusalSpec(
37
+ "terrine.increment.duplicate_yaml_key",
38
+ "increment YAML repeats mapping key {key!r} in {path!r}.",
39
+ )
40
+ _UNSUPPORTED = RefusalSpec(
41
+ "terrine.increment.unsupported_definition",
42
+ "increment declaration {declaration!r} has semantics not represented by the native Catalog.",
43
+ )
44
+ _INVALID_DEFINITIONS = RefusalSpec(
45
+ "terrine.increment.invalid_definitions",
46
+ "increment definitions were rejected with upstream code {upstream_code!r}.",
47
+ )
48
+ _PROPERTY_COLLISION = RefusalSpec(
49
+ "terrine.increment.property_name_collision",
50
+ "increment property names cannot be qualified without a Catalog name collision: {name!r}.",
51
+ )
52
+ _AMBIGUOUS_PROPERTY = RefusalSpec(
53
+ "terrine.increment.ambiguous_property",
54
+ "increment filter property {property!r} is ambiguous in source {source!r}.",
55
+ )
56
+
57
+
58
+ class _DuplicateKey(Exception):
59
+ def __init__(self, key: object) -> None:
60
+ self.key = key
61
+
62
+
63
+ class _UniqueSafeLoader(yaml.SafeLoader):
64
+ pass
65
+
66
+
67
+ def _construct_unique_mapping(loader: _UniqueSafeLoader, node, deep: bool = False):
68
+ mapping = {}
69
+ for key_node, value_node in node.value:
70
+ key = loader.construct_object(key_node, deep=deep)
71
+ try:
72
+ duplicate = key in mapping
73
+ except TypeError as exc:
74
+ raise ConstructorError(
75
+ "while constructing a mapping",
76
+ node.start_mark,
77
+ "unhashable mapping key",
78
+ key_node.start_mark,
79
+ ) from exc
80
+ if duplicate:
81
+ raise _DuplicateKey(key)
82
+ mapping[key] = loader.construct_object(value_node, deep=deep)
83
+ return mapping
84
+
85
+
86
+ _UniqueSafeLoader.add_constructor(BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping)
87
+
88
+
89
+ def _check_duplicate_keys(path: Path) -> None:
90
+ yaml_files = (
91
+ (path,) if path.is_file() else tuple(sorted((*path.rglob("*.yaml"), *path.rglob("*.yml"))))
92
+ )
93
+ for yaml_path in yaml_files:
94
+ try:
95
+ with yaml_path.open(encoding="utf-8") as stream:
96
+ yaml.load(stream, Loader=_UniqueSafeLoader)
97
+ except _DuplicateKey as exc:
98
+ raise _DUPLICATE_YAML_KEY.error(key=str(exc.key), path=str(yaml_path)) from exc
99
+ except yaml.YAMLError:
100
+ # Let increment's loader provide its coded YAML refusal.
101
+ continue
102
+
103
+
104
+ def _add_property_name(
105
+ raw_name: str,
106
+ scope_kind: str,
107
+ scope: str,
108
+ counts: Counter[str],
109
+ scope_counts: Counter[str],
110
+ names: dict[tuple[str, str, str], str],
111
+ ) -> None:
112
+ if counts[raw_name] == 1:
113
+ name = raw_name
114
+ elif scope_counts[scope] > 1:
115
+ name = f"{scope_kind}.{scope}.{raw_name}"
116
+ else:
117
+ name = f"{scope}.{raw_name}"
118
+ names[(scope_kind, scope, raw_name)] = name
119
+
120
+
121
+ def _filter_records(
122
+ filters: Any,
123
+ *,
124
+ source: str,
125
+ available_scopes: tuple[tuple[str, str], ...],
126
+ names: dict[tuple[str, str, str], str],
127
+ ):
128
+ records = []
129
+ for source_filter in filters:
130
+ matching = [
131
+ names[(scope_kind, scope, source_filter.property)]
132
+ for scope_kind, scope in available_scopes
133
+ if (scope_kind, scope, source_filter.property) in names
134
+ ]
135
+ if len(matching) > 1:
136
+ raise _AMBIGUOUS_PROPERTY.error(property=source_filter.property, source=source)
137
+ property_name = matching[0] if matching else source_filter.property
138
+ records.append(
139
+ Filter(
140
+ property=property_name,
141
+ op=source_filter.op,
142
+ values=tuple(source_filter.values),
143
+ )
144
+ )
145
+ return tuple(records)
146
+
147
+
148
+ def _measure(
149
+ fact: str,
150
+ aggregation: Literal[
151
+ "sum", "count", "avg_event", "avg_calendar_day", "min", "max", "count_distinct"
152
+ ],
153
+ filters: Any,
154
+ window_days: int | None,
155
+ *,
156
+ source: str,
157
+ scopes: tuple[tuple[str, str], ...],
158
+ names: dict[tuple[str, str, str], str],
159
+ ) -> Measure:
160
+ return Measure(
161
+ fact=fact,
162
+ aggregation=aggregation,
163
+ filters=_filter_records(filters, source=source, available_scopes=scopes, names=names),
164
+ window_days=window_days,
165
+ )
166
+
167
+
168
+ def to_catalog(path_or_definitions: Any, *, dialect: str) -> Catalog:
169
+ """Convert increment's published semantic definitions to a native Catalog.
170
+
171
+ The optional dependency is loaded only when this bridge is invoked. The bridge
172
+ projects fact sources, metrics, and referenced dimensions; exposure, experiment,
173
+ week-start, and unreferenced-dimension declarations are not interpreted. Source
174
+ SQL is re-admitted by Terrine and execution remains the native Model.from_catalog
175
+ path.
176
+ """
177
+ try:
178
+ semantics = import_module("increment.semantics")
179
+ metric_models = import_module("increment.semantics.models")
180
+ except (ImportError, ModuleNotFoundError) as exc:
181
+ raise _MISSING.error(extra="increment") from exc
182
+
183
+ if isinstance(path_or_definitions, semantics.Definitions):
184
+ definitions = path_or_definitions
185
+ else:
186
+ path = Path(path_or_definitions)
187
+ _check_duplicate_keys(path)
188
+ try:
189
+ definitions = semantics.load(path)
190
+ except semantics.DefinitionError as exc:
191
+ raise _INVALID_DEFINITIONS.error(upstream_code=exc.code) from exc
192
+
193
+ fact_sources = {source.name: source for source in definitions.fact_sources}
194
+ dimension_sources = {source.name: source for source in definitions.dim_sources}
195
+ referenced_dimensions = {
196
+ dimension_name for source in definitions.fact_sources for dimension_name in source.dims
197
+ }
198
+
199
+ scopes_by_property: dict[str, list[tuple[str, str]]] = {}
200
+ for source in definitions.fact_sources:
201
+ for prop in source.properties:
202
+ scopes_by_property.setdefault(prop.name, []).append(("source", source.name))
203
+ for dimension in definitions.dim_sources:
204
+ if dimension.name in referenced_dimensions:
205
+ for prop in dimension.properties:
206
+ scopes_by_property.setdefault(prop.name, []).append(("dimension", dimension.name))
207
+ counts = Counter({name: len(scopes) for name, scopes in scopes_by_property.items()})
208
+ property_names: dict[tuple[str, str, str], str] = {}
209
+ for raw_name, scopes in scopes_by_property.items():
210
+ scope_counts = Counter(scope for _, scope in scopes)
211
+ for scope_kind, scope in scopes:
212
+ _add_property_name(raw_name, scope_kind, scope, counts, scope_counts, property_names)
213
+ projected_names = list(property_names.values())
214
+ if len(set(projected_names)) != len(projected_names):
215
+ duplicate = next(name for name, count in Counter(projected_names).items() if count > 1)
216
+ raise _PROPERTY_COLLISION.error(name=duplicate)
217
+
218
+ sources: dict[str, Source] = {}
219
+ facts: dict[str, Fact] = {}
220
+ properties: dict[str, Property] = {}
221
+ for source in definitions.fact_sources:
222
+ source_properties = []
223
+ for source_fact in source.facts:
224
+ facts[source_fact.name] = Fact(source=source.name, column=source_fact.column)
225
+ for source_property in source.properties:
226
+ name = property_names[("source", source.name, source_property.name)]
227
+ source_properties.append(name)
228
+ properties[name] = Property(
229
+ source=source.name,
230
+ column=source_property.column,
231
+ dtype=source_property.dtype,
232
+ as_of=source_property.as_of,
233
+ )
234
+ sources[source.name] = Source(
235
+ relation=SqlQuery(kind="sql", sql=source.sql),
236
+ timestamp=source.timestamp_column,
237
+ entities={column: column for column in source.entities},
238
+ facts=tuple(fact.name for fact in source.facts),
239
+ properties=tuple(source_properties),
240
+ dimensions=tuple(source.dims),
241
+ )
242
+
243
+ dimensions: dict[str, Dimension] = {}
244
+ for dimension_name in sorted(referenced_dimensions):
245
+ dimension = dimension_sources[dimension_name]
246
+ owners = sorted(
247
+ source.name for source in definitions.fact_sources if dimension_name in source.dims
248
+ )
249
+ owner = owners[0]
250
+ dimension_properties = []
251
+ for source_property in dimension.properties:
252
+ name = property_names[("dimension", dimension.name, source_property.name)]
253
+ dimension_properties.append(name)
254
+ properties[name] = Property(
255
+ source=owner,
256
+ column=source_property.column,
257
+ dtype=source_property.dtype,
258
+ as_of=source_property.as_of,
259
+ dimension=dimension.name,
260
+ )
261
+ validity = (
262
+ Validity(
263
+ valid_from=dimension.validity.valid_from,
264
+ valid_to=dimension.validity.valid_to,
265
+ changed_at=dimension.validity.changed_at,
266
+ )
267
+ if dimension.validity is not None
268
+ else None
269
+ )
270
+ dimensions[dimension.name] = Dimension(
271
+ relation=SqlQuery(kind="sql", sql=dimension.sql),
272
+ source_entity=dimension.entity,
273
+ entity_column=dimension.entity,
274
+ validity=validity,
275
+ properties=tuple(dimension_properties),
276
+ )
277
+
278
+ measures: dict[str, Measure] = {}
279
+ metrics: dict[str, Metric] = {}
280
+ for metric in definitions.metrics:
281
+ kind = metric.type
282
+ if not isinstance(
283
+ metric,
284
+ (metric_models.MeanMetric, metric_models.ConversionMetric, metric_models.RatioMetric),
285
+ ):
286
+ raise UNSUPPORTED_TEMPORAL_KIND.error(kind=kind)
287
+ if isinstance(metric, metric_models.RatioMetric):
288
+ numerator_name = f"{metric.name}.numerator"
289
+ denominator_name = f"{metric.name}.denominator"
290
+ for name, part in (
291
+ (numerator_name, metric.numerator),
292
+ (denominator_name, metric.denominator),
293
+ ):
294
+ if name in measures:
295
+ raise _UNSUPPORTED.error(declaration=f"measure name collision {name!r}")
296
+ owner = next(
297
+ source.name
298
+ for source in definitions.fact_sources
299
+ if any(fact.name == part.fact for fact in source.facts)
300
+ )
301
+ source = fact_sources[owner]
302
+ measures[name] = _measure(
303
+ part.fact,
304
+ part.aggregation,
305
+ part.filters,
306
+ part.window_days,
307
+ source=owner,
308
+ scopes=(("source", owner), *(("dimension", name) for name in source.dims)),
309
+ names=property_names,
310
+ )
311
+ metrics[metric.name] = Metric(
312
+ kind=kind,
313
+ entity=metric.entity,
314
+ numerator=numerator_name,
315
+ denominator=denominator_name,
316
+ )
317
+ else:
318
+ name = metric.name
319
+ if name in measures:
320
+ raise _UNSUPPORTED.error(declaration=f"measure name collision {name!r}")
321
+ owner = next(
322
+ source.name
323
+ for source in definitions.fact_sources
324
+ if any(fact.name == metric.fact for fact in source.facts)
325
+ )
326
+ source = fact_sources[owner]
327
+ aggregation = (
328
+ metric.aggregation if isinstance(metric, metric_models.MeanMetric) else "count"
329
+ )
330
+ measures[name] = _measure(
331
+ metric.fact,
332
+ aggregation,
333
+ metric.filters,
334
+ metric.window_days,
335
+ source=owner,
336
+ scopes=(("source", owner), *(("dimension", dim_name) for dim_name in source.dims)),
337
+ names=property_names,
338
+ )
339
+ winsorization = (
340
+ metric.winsorization.model_dump(mode="json")
341
+ if isinstance(metric, metric_models.MeanMetric) and metric.winsorization is not None
342
+ else None
343
+ )
344
+ metrics[metric.name] = Metric(
345
+ kind=kind, entity=metric.entity, measure=name, winsorization=winsorization
346
+ )
347
+
348
+ catalog_dialect = definitions.dialect or dialect
349
+ for source in definitions.fact_sources:
350
+ admit_read_query(source.sql, catalog_dialect, dialect)
351
+ for dimension in definitions.dim_sources:
352
+ if dimension.name in referenced_dimensions:
353
+ admit_read_query(dimension.sql, catalog_dialect, dialect)
354
+
355
+ return Catalog(
356
+ schema_version=1,
357
+ day_boundary=definitions.day_boundary,
358
+ dialect=catalog_dialect,
359
+ sources=sources,
360
+ facts=facts,
361
+ properties=properties,
362
+ dimensions=dimensions,
363
+ measures=measures,
364
+ metrics=metrics,
365
+ )
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import queue
4
+ import threading
5
+ from typing import Any
6
+
7
+ __all__ = ["OpenTelemetryAdapter"]
8
+
9
+ _STATES = frozenset({"queued", "running", "cancel_requested", "succeeded", "failed", "cancelled"})
10
+
11
+
12
+ class OpenTelemetryAdapter:
13
+ """Project safe lifecycle states to application-configured OpenTelemetry instruments."""
14
+
15
+ def __init__(self, tracer: Any, meter: Any, *, queue_size: int = 128) -> None:
16
+ if isinstance(queue_size, bool) or queue_size < 1:
17
+ raise ValueError("queue_size must be positive")
18
+ self._tracer = tracer
19
+ self._events: queue.Queue[str] = queue.Queue(maxsize=queue_size)
20
+ try:
21
+ self._counter = meter.create_counter("terrine.runtime.lifecycle")
22
+ except Exception:
23
+ self._counter = None
24
+ self._stopping = threading.Event()
25
+ self._worker = threading.Thread(target=self._export, name="terrine-otel", daemon=True)
26
+ self._worker.start()
27
+
28
+ def observe_event(self, event: Any) -> None:
29
+ """Queue an allowlisted lifecycle state without blocking the caller."""
30
+ if event.kind != "lifecycle" or not isinstance(event.payload, dict):
31
+ return
32
+ state = event.payload.get("state")
33
+ if not isinstance(state, str) or state not in _STATES:
34
+ return
35
+ try:
36
+ self._events.put_nowait(state)
37
+ except queue.Full:
38
+ pass
39
+
40
+ def close(self) -> None:
41
+ """Request worker shutdown without waiting indefinitely on an exporter."""
42
+ self._stopping.set()
43
+ self._worker.join(timeout=0.05)
44
+
45
+ def _export(self) -> None:
46
+ while not self._stopping.is_set() or not self._events.empty():
47
+ try:
48
+ state = self._events.get(timeout=0.05)
49
+ except queue.Empty:
50
+ continue
51
+ attributes = {"terrine.run.state": state}
52
+ try:
53
+ if self._counter is not None:
54
+ self._counter.add(1, attributes)
55
+ except Exception:
56
+ pass
57
+ try:
58
+ span = self._tracer.start_span("terrine.runtime.lifecycle", attributes=attributes)
59
+ span.end()
60
+ except Exception:
61
+ pass
62
+ finally:
63
+ self._events.task_done()
@@ -0,0 +1,6 @@
1
+ """Constrained dataset tools configured by a trusted owner."""
2
+
3
+ from terrine.agent.policy import AgentPolicy
4
+ from terrine.agent.tools import DatasetTools
5
+
6
+ __all__ = ["AgentPolicy", "DatasetTools"]