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.
- terrine/__init__.py +120 -0
- terrine/_time.py +21 -0
- terrine/adapters/__init__.py +3 -0
- terrine/adapters/increment.py +365 -0
- terrine/adapters/opentelemetry.py +63 -0
- terrine/agent/__init__.py +6 -0
- terrine/agent/definitions.py +143 -0
- terrine/agent/design_schema.py +220 -0
- terrine/agent/policy.py +144 -0
- terrine/agent/schemas.py +285 -0
- terrine/agent/tools.py +956 -0
- terrine/backends/__init__.py +3 -0
- terrine/backends/capabilities.py +117 -0
- terrine/backends/dialect.py +55 -0
- terrine/backends/extrema.py +40 -0
- terrine/backends/joins.py +75 -0
- terrine/backends/numeric.py +657 -0
- terrine/backends/sequences.py +40 -0
- terrine/backends/timestamps.py +421 -0
- terrine/compile/__init__.py +3 -0
- terrine/compile/_asof.py +165 -0
- terrine/compile/_predicates.py +110 -0
- terrine/compile/anchors.py +492 -0
- terrine/compile/columns.py +1353 -0
- terrine/compile/cost.py +110 -0
- terrine/compile/dispatch.py +148 -0
- terrine/compile/layers.py +242 -0
- terrine/compile/materialize.py +91 -0
- terrine/compile/provenance.py +120 -0
- terrine/compile/ranges.py +91 -0
- terrine/compile/retention.py +127 -0
- terrine/compile/shapes/__init__.py +3 -0
- terrine/compile/shapes/_observations.py +502 -0
- terrine/compile/shapes/calendar_comparison.py +370 -0
- terrine/compile/shapes/cohorts.py +285 -0
- terrine/compile/shapes/event_panel.py +46 -0
- terrine/compile/shapes/event_sequence.py +498 -0
- terrine/compile/shapes/life_table.py +244 -0
- terrine/compile/shapes/ltv.py +287 -0
- terrine/compile/shapes/matched_t0.py +75 -0
- terrine/compile/shapes/sequential.py +320 -0
- terrine/compile/shapes/state_panel.py +460 -0
- terrine/compile/shapes/time_to_event.py +521 -0
- terrine/data.py +1311 -0
- terrine/dataset/__init__.py +5 -0
- terrine/dataset/export.py +463 -0
- terrine/dataset/manifest.py +254 -0
- terrine/dataset/reference.py +582 -0
- terrine/dataset/splits.py +373 -0
- terrine/dataset/training.py +114 -0
- terrine/dataset/transforms.py +182 -0
- terrine/errors.py +98 -0
- terrine/execution/__init__.py +5 -0
- terrine/execution/_bigquery.py +318 -0
- terrine/execution/_metadata.py +47 -0
- terrine/execution/_native.py +574 -0
- terrine/execution/_postgres.py +468 -0
- terrine/execution/_publish.py +299 -0
- terrine/execution/_snowflake.py +684 -0
- terrine/execution/backend.py +349 -0
- terrine/execution/gates.py +90 -0
- terrine/execution/inputs.py +923 -0
- terrine/export/__init__.py +31 -0
- terrine/export/diff_diff.py +177 -0
- terrine/export/lifelines.py +219 -0
- terrine/export/preflight.py +243 -0
- terrine/export/surrogate.py +168 -0
- terrine/frame.py +1042 -0
- terrine/model.py +397 -0
- terrine/orchestration/__init__.py +3 -0
- terrine/orchestration/airflow.py +115 -0
- terrine/orchestration/dagster.py +103 -0
- terrine/py.typed +0 -0
- terrine/recipes.py +295 -0
- terrine/runtime/__init__.py +70 -0
- terrine/runtime/api.py +803 -0
- terrine/runtime/arrow.py +98 -0
- terrine/runtime/backends/base.py +70 -0
- terrine/runtime/backends/duckdb.py +645 -0
- terrine/runtime/changes.py +71 -0
- terrine/runtime/cli.py +108 -0
- terrine/runtime/control.py +355 -0
- terrine/runtime/cost.py +211 -0
- terrine/runtime/events.py +194 -0
- terrine/runtime/execute.py +932 -0
- terrine/runtime/gc.py +43 -0
- terrine/runtime/graph.py +200 -0
- terrine/runtime/layers.py +61 -0
- terrine/runtime/leases.py +165 -0
- terrine/runtime/materialize.py +53 -0
- terrine/runtime/owner.py +57 -0
- terrine/runtime/planner.py +441 -0
- terrine/runtime/planning.py +394 -0
- terrine/runtime/saved_plan.py +337 -0
- terrine/runtime/snapshot.py +541 -0
- terrine/runtime/state.py +585 -0
- terrine/runtime/status.py +360 -0
- terrine/runtime/synchronization.py +15 -0
- terrine/semantic/__init__.py +3 -0
- terrine/semantic/bind.py +309 -0
- terrine/semantic/io.py +116 -0
- terrine/semantic/models.py +342 -0
- terrine/semantic/rules.py +113 -0
- terrine/semantic/sql.py +185 -0
- terrine/semantic/time.py +42 -0
- terrine/simulate/__init__.py +24 -0
- terrine/simulate/baseline.yaml +57 -0
- terrine/simulate/generate.py +182 -0
- terrine/simulate/scale.py +234 -0
- terrine/simulate/world.py +75 -0
- terrine/spec/__init__.py +96 -0
- terrine/spec/anchors.py +107 -0
- terrine/spec/dataset.py +260 -0
- terrine/spec/design.py +361 -0
- terrine/spec/expressions.py +440 -0
- terrine/spec/io.py +829 -0
- terrine/spec/panels.py +359 -0
- terrine/spec/sequence.py +165 -0
- terrine/spec/state_panel.py +246 -0
- terrine/spec/trajectory.py +144 -0
- terrine/spec/transforms.py +260 -0
- terrine/spec/validate.py +758 -0
- terrine/temporal/__init__.py +3 -0
- terrine/temporal/aggregate.py +688 -0
- terrine/temporal/attributes.py +389 -0
- terrine/temporal/daily.py +481 -0
- terrine/temporal/first.py +239 -0
- terrine/temporal/lookup.py +194 -0
- terrine/temporal/nonadditive.py +466 -0
- terrine/temporal/prefix.py +104 -0
- terrine/temporal/ranges.py +122 -0
- terrine/temporal/readiness.py +41 -0
- terrine/temporal/retention.py +225 -0
- terrine/temporal/sequence.py +900 -0
- terrine/temporal/sessionize.py +135 -0
- terrine/temporal/sketches.py +329 -0
- terrine/windows.py +54 -0
- terrine-0.1.0.dev0.dist-info/METADATA +164 -0
- terrine-0.1.0.dev0.dist-info/RECORD +144 -0
- terrine-0.1.0.dev0.dist-info/WHEEL +4 -0
- terrine-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- terrine-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- terrine-0.1.0.dev0.dist-info/licenses/NOTICE +4 -0
- terrine-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.txt +219 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Typed discovery records for the existing DatasetDefinition codec."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Annotated, Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator
|
|
8
|
+
|
|
9
|
+
from terrine._time import utc_instant
|
|
10
|
+
from terrine.agent.design_schema import design_schema
|
|
11
|
+
from terrine.dataset.splits import policy_from_record
|
|
12
|
+
from terrine.errors import thaw
|
|
13
|
+
from terrine.model import Model
|
|
14
|
+
from terrine.spec.anchors import FixedDate
|
|
15
|
+
from terrine.spec.dataset import INVALID_DEFINITION, DatasetDefinition
|
|
16
|
+
from terrine.spec.io import from_json
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ViewRecord(BaseModel):
|
|
20
|
+
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
|
21
|
+
features: tuple[str, ...]
|
|
22
|
+
|
|
23
|
+
@field_validator("features")
|
|
24
|
+
@classmethod
|
|
25
|
+
def _features(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
26
|
+
if len(set(value)) != len(value) or any(not item for item in value):
|
|
27
|
+
raise ValueError("features must contain unique nonempty names")
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GroupRecord(BaseModel):
|
|
32
|
+
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
|
33
|
+
kind: Literal["group"]
|
|
34
|
+
group: str
|
|
35
|
+
train_fraction: str
|
|
36
|
+
validation_fraction: str
|
|
37
|
+
seed: int
|
|
38
|
+
codec: Literal["terrine.group.v1"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TimeRecord(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
|
43
|
+
kind: Literal["time"]
|
|
44
|
+
train_until: str
|
|
45
|
+
validation_until: str | None
|
|
46
|
+
embargo_microseconds: Annotated[int, Field(ge=0)]
|
|
47
|
+
groups: GroupRecord | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TrainingView(ViewRecord):
|
|
51
|
+
kind: Literal["training"]
|
|
52
|
+
labels: Annotated[tuple[str, ...], Field(min_length=1)]
|
|
53
|
+
incomplete_labels: Literal["drop", "keep"]
|
|
54
|
+
split: Annotated[TimeRecord | GroupRecord, Field(discriminator="kind")] | None
|
|
55
|
+
|
|
56
|
+
@field_validator("labels")
|
|
57
|
+
@classmethod
|
|
58
|
+
def _labels(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
59
|
+
return cls._features(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ScoringView(ViewRecord):
|
|
63
|
+
kind: Literal["scoring"]
|
|
64
|
+
as_of: str
|
|
65
|
+
eligibility: dict[str, Any]
|
|
66
|
+
parent_design_hash: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
|
|
67
|
+
|
|
68
|
+
@field_validator("as_of")
|
|
69
|
+
@classmethod
|
|
70
|
+
def _instant(cls, value: str) -> str:
|
|
71
|
+
utc_instant(value)
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_VIEW = TypeAdapter(Annotated[TrainingView | ScoringView, Field(discriminator="kind")])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_definition(serialized: str, model: Model) -> DatasetDefinition:
|
|
79
|
+
definition = DatasetDefinition.from_json(serialized)
|
|
80
|
+
if definition.view is not None:
|
|
81
|
+
try:
|
|
82
|
+
_VIEW.validate_json(json.dumps(thaw(definition.view)))
|
|
83
|
+
if definition.view["kind"] == "training" and definition.view["split"] is not None:
|
|
84
|
+
policy_from_record(thaw(definition.view["split"]))
|
|
85
|
+
except (ValueError, OverflowError) as error:
|
|
86
|
+
raise INVALID_DEFINITION.error(
|
|
87
|
+
reason="use the discovered typed training or scoring view"
|
|
88
|
+
) from error
|
|
89
|
+
if definition.view["kind"] == "scoring":
|
|
90
|
+
design = definition.base_design
|
|
91
|
+
if (
|
|
92
|
+
design.shape != "cross_section"
|
|
93
|
+
or not isinstance(design.anchor, FixedDate)
|
|
94
|
+
or design.outcomes
|
|
95
|
+
or set(definition.view["features"]) != set(design.features)
|
|
96
|
+
):
|
|
97
|
+
raise INVALID_DEFINITION.error(
|
|
98
|
+
reason="scoring view must match its fixed feature-only base Design"
|
|
99
|
+
)
|
|
100
|
+
anchor = design.anchor.date
|
|
101
|
+
resolved = (
|
|
102
|
+
utc_instant(anchor) if isinstance(anchor, datetime) else model.day_boundary(anchor)
|
|
103
|
+
)
|
|
104
|
+
if utc_instant(definition.view["as_of"]) != resolved:
|
|
105
|
+
raise INVALID_DEFINITION.error(
|
|
106
|
+
reason="scoring instant must match its base Design anchor"
|
|
107
|
+
)
|
|
108
|
+
document = json.loads(definition.canonical_json())["base_design"]
|
|
109
|
+
document["eligibility"] = thaw(definition.view["eligibility"])
|
|
110
|
+
try:
|
|
111
|
+
eligibility_design = from_json(
|
|
112
|
+
json.dumps({"schema_version": 1, "design": document})
|
|
113
|
+
)
|
|
114
|
+
except (KeyError, TypeError, ValueError, OverflowError) as error:
|
|
115
|
+
raise INVALID_DEFINITION.error(
|
|
116
|
+
reason="scoring eligibility must use typed predicates"
|
|
117
|
+
) from error
|
|
118
|
+
if eligibility_design.eligibility != design.eligibility:
|
|
119
|
+
raise INVALID_DEFINITION.error(
|
|
120
|
+
reason="scoring eligibility must match its base Design"
|
|
121
|
+
)
|
|
122
|
+
return definition
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def definition_schema() -> dict[str, Any]:
|
|
126
|
+
design = design_schema()
|
|
127
|
+
view = _VIEW.json_schema()
|
|
128
|
+
definitions = {**design["$defs"], **view.pop("$defs", {})}
|
|
129
|
+
definitions["ScoringView"]["properties"]["eligibility"] = definitions["Design"]["properties"][
|
|
130
|
+
"eligibility"
|
|
131
|
+
]
|
|
132
|
+
return {
|
|
133
|
+
"$schema": design["$schema"],
|
|
134
|
+
"$defs": definitions,
|
|
135
|
+
"type": "object",
|
|
136
|
+
"additionalProperties": False,
|
|
137
|
+
"required": ["schema_version", "base_design", "view"],
|
|
138
|
+
"properties": {
|
|
139
|
+
"schema_version": {"const": 1},
|
|
140
|
+
"base_design": {"$ref": "#/$defs/Design"},
|
|
141
|
+
"view": {"anyOf": [view, {"type": "null"}]},
|
|
142
|
+
},
|
|
143
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Discovery schema for the existing versioned Design codec."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def design_schema() -> dict[str, Any]:
|
|
7
|
+
def ref(name):
|
|
8
|
+
return {"$ref": f"#/$defs/{name}"}
|
|
9
|
+
|
|
10
|
+
def union(*names):
|
|
11
|
+
return {"anyOf": [ref(name) for name in names]}
|
|
12
|
+
|
|
13
|
+
def nullable(schema):
|
|
14
|
+
return {"anyOf": [schema, {"type": "null"}]}
|
|
15
|
+
|
|
16
|
+
def array(schema):
|
|
17
|
+
return {"type": "array", "items": schema}
|
|
18
|
+
|
|
19
|
+
def mapping(schema):
|
|
20
|
+
return {"type": "object", "additionalProperties": schema}
|
|
21
|
+
|
|
22
|
+
def obj(properties, required=None):
|
|
23
|
+
return {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": properties,
|
|
26
|
+
"required": list(properties) if required is None else required,
|
|
27
|
+
"additionalProperties": False,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
text = {"type": "string"}
|
|
31
|
+
number = {"type": "number"}
|
|
32
|
+
integer = {"type": "integer"}
|
|
33
|
+
boolean = {"type": "boolean"}
|
|
34
|
+
scalar = {"type": ["string", "number", "boolean", "null"]}
|
|
35
|
+
duration = {"type": ["integer", "string"]}
|
|
36
|
+
definitions = {"Temporal": obj({"type": {"enum": ["date", "datetime"]}, "value": text})}
|
|
37
|
+
definitions["Cutpoint"] = {
|
|
38
|
+
"anyOf": [
|
|
39
|
+
obj({"type": {"enum": ["int", "float64"]}, "value": text}),
|
|
40
|
+
obj(
|
|
41
|
+
{
|
|
42
|
+
"type": {"const": "decimal"},
|
|
43
|
+
"sign": {"enum": [0, 1]},
|
|
44
|
+
"digits": array({"type": "integer", "minimum": 0, "maximum": 9}),
|
|
45
|
+
"exponent": integer,
|
|
46
|
+
}
|
|
47
|
+
),
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
expression = union("MetricAgg", "AttributeAsOf", "EventTime")
|
|
51
|
+
predicate = union("Gt", "Eq", "IsNull", "And")
|
|
52
|
+
named = mapping(expression)
|
|
53
|
+
outputs = mapping({"anyOf": [expression, predicate]})
|
|
54
|
+
nodes = {
|
|
55
|
+
"Window": {"start": number, "end": number},
|
|
56
|
+
"FixedDate": {"date": ref("Temporal")},
|
|
57
|
+
"EventAnchor": {
|
|
58
|
+
"fact": text,
|
|
59
|
+
"which": {"enum": ["first", "nth", "each"]},
|
|
60
|
+
"n": nullable(integer),
|
|
61
|
+
},
|
|
62
|
+
"Schedule": {"every": duration, "start": ref("Temporal"), "end": ref("Temporal")},
|
|
63
|
+
"AnchorRange": {"start": nullable(ref("Temporal")), "end": nullable(ref("Temporal"))},
|
|
64
|
+
"MetricAgg": {
|
|
65
|
+
"metric": text,
|
|
66
|
+
"window": ref("Window"),
|
|
67
|
+
"relative_to": nullable(ref("EventTime")),
|
|
68
|
+
},
|
|
69
|
+
"AttributeAsOf": {
|
|
70
|
+
"attribute": text,
|
|
71
|
+
"before": number,
|
|
72
|
+
"relative_to": nullable(ref("EventTime")),
|
|
73
|
+
},
|
|
74
|
+
"EventTime": {
|
|
75
|
+
"fact": text,
|
|
76
|
+
"window": ref("Window"),
|
|
77
|
+
"label_property": nullable(text),
|
|
78
|
+
"equals": scalar,
|
|
79
|
+
"occurrence": {"enum": ["first", "last", "nth"]},
|
|
80
|
+
"n": nullable(integer),
|
|
81
|
+
"relative_to": nullable(ref("EventTime")),
|
|
82
|
+
},
|
|
83
|
+
"Gt": {"expr": expression, "value": scalar},
|
|
84
|
+
"Eq": {"left": expression, "value": scalar},
|
|
85
|
+
"IsNull": {"expr": expression, "negate": boolean},
|
|
86
|
+
"And": {"terms": array(predicate)},
|
|
87
|
+
"Treatment": {"attribute": text, "at": number, "known_at_t0": boolean},
|
|
88
|
+
"StateObservations": {
|
|
89
|
+
"property": text,
|
|
90
|
+
"levels": array(scalar),
|
|
91
|
+
"ordered": boolean,
|
|
92
|
+
"absorbing": array(scalar),
|
|
93
|
+
},
|
|
94
|
+
"BinnedMetricState": {
|
|
95
|
+
"metric": text,
|
|
96
|
+
"cutpoints": array(ref("Cutpoint")),
|
|
97
|
+
"levels": array(scalar),
|
|
98
|
+
},
|
|
99
|
+
"StatePanel": {
|
|
100
|
+
"state": union("StateObservations", "BinnedMetricState"),
|
|
101
|
+
"max_periods": integer,
|
|
102
|
+
"baseline_features": named,
|
|
103
|
+
},
|
|
104
|
+
"Trajectory": {
|
|
105
|
+
"metric": text,
|
|
106
|
+
"period": duration,
|
|
107
|
+
"periods": integer,
|
|
108
|
+
"cumulative": boolean,
|
|
109
|
+
},
|
|
110
|
+
"CohortSpec": {"origin": text, "trajectory": ref("Trajectory")},
|
|
111
|
+
"TimeToEvent": {
|
|
112
|
+
"event": text,
|
|
113
|
+
"exit": nullable(text),
|
|
114
|
+
"entry": nullable(text),
|
|
115
|
+
"delayed_entry": boolean,
|
|
116
|
+
},
|
|
117
|
+
"LifeTable": {
|
|
118
|
+
"time_to_event": ref("TimeToEvent"),
|
|
119
|
+
"interval": duration,
|
|
120
|
+
"group_by": array(text),
|
|
121
|
+
},
|
|
122
|
+
"SessionSpec": {"gap_seconds": number},
|
|
123
|
+
"FunnelSpec": {
|
|
124
|
+
"event_property": text,
|
|
125
|
+
"steps": array(text),
|
|
126
|
+
"within_seconds": nullable(duration),
|
|
127
|
+
},
|
|
128
|
+
"EventSequence": {
|
|
129
|
+
"fact": text,
|
|
130
|
+
"window": ref("Window"),
|
|
131
|
+
"max_events": integer,
|
|
132
|
+
"representation": text,
|
|
133
|
+
"properties": array(text),
|
|
134
|
+
"label_property": nullable(text),
|
|
135
|
+
"sessions": nullable(ref("SessionSpec")),
|
|
136
|
+
"funnel": nullable(ref("FunnelSpec")),
|
|
137
|
+
},
|
|
138
|
+
"Initiation": {"source": text, "property": text, "history_start": text, "knowledge": text},
|
|
139
|
+
"PersistentAdoption": {
|
|
140
|
+
"source": text,
|
|
141
|
+
"property": text,
|
|
142
|
+
"adopted_value": scalar,
|
|
143
|
+
"history_start": text,
|
|
144
|
+
"persistence": text,
|
|
145
|
+
"knowledge": text,
|
|
146
|
+
},
|
|
147
|
+
"MatchedT0Sample": {
|
|
148
|
+
"strata": array(text),
|
|
149
|
+
"controls_per_treated": integer,
|
|
150
|
+
"seed": integer,
|
|
151
|
+
},
|
|
152
|
+
"TrialWeight": {"kind": text, "values": nullable(mapping(number))},
|
|
153
|
+
"SequentialSpec": {
|
|
154
|
+
"treatment": union("Initiation", "PersistentAdoption"),
|
|
155
|
+
"trial_weight": nullable(ref("TrialWeight")),
|
|
156
|
+
"anticipation_seconds": number,
|
|
157
|
+
"reference_period_seconds": number,
|
|
158
|
+
},
|
|
159
|
+
"EventPanelSpec": {
|
|
160
|
+
"periods": array(integer),
|
|
161
|
+
"period_seconds": number,
|
|
162
|
+
"reference_period": integer,
|
|
163
|
+
},
|
|
164
|
+
"CalendarComparisonSpec": {
|
|
165
|
+
"cohort": ref("Initiation"),
|
|
166
|
+
"control": text,
|
|
167
|
+
"reference_period": integer,
|
|
168
|
+
"comparison_periods": array(integer),
|
|
169
|
+
"anticipation_periods": array(integer),
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
optional = {
|
|
173
|
+
"EventAnchor": {"n"},
|
|
174
|
+
"MetricAgg": {"relative_to"},
|
|
175
|
+
"AttributeAsOf": {"relative_to"},
|
|
176
|
+
"EventTime": {"relative_to"},
|
|
177
|
+
"IsNull": {"negate"},
|
|
178
|
+
"Treatment": {"at"},
|
|
179
|
+
"StateObservations": {"ordered", "absorbing"},
|
|
180
|
+
}
|
|
181
|
+
for name, fields in nodes.items():
|
|
182
|
+
properties = {"type": {"const": name}, **fields}
|
|
183
|
+
definitions[name] = obj(
|
|
184
|
+
properties, [key for key in properties if key not in optional.get(name, set())]
|
|
185
|
+
)
|
|
186
|
+
definitions["Design"] = obj(
|
|
187
|
+
{
|
|
188
|
+
"unit": text,
|
|
189
|
+
"anchor": union("FixedDate", "EventAnchor", "Schedule"),
|
|
190
|
+
"features": outputs,
|
|
191
|
+
"outcomes": outputs,
|
|
192
|
+
"eligibility": mapping(predicate),
|
|
193
|
+
"treatment": nullable(ref("Treatment")),
|
|
194
|
+
"gap": number,
|
|
195
|
+
"shape": {
|
|
196
|
+
"anyOf": [
|
|
197
|
+
text,
|
|
198
|
+
union(
|
|
199
|
+
"StatePanel",
|
|
200
|
+
"CohortSpec",
|
|
201
|
+
"TimeToEvent",
|
|
202
|
+
"LifeTable",
|
|
203
|
+
"EventSequence",
|
|
204
|
+
"SequentialSpec",
|
|
205
|
+
"EventPanelSpec",
|
|
206
|
+
"CalendarComparisonSpec",
|
|
207
|
+
),
|
|
208
|
+
]
|
|
209
|
+
},
|
|
210
|
+
"anchor_range": nullable(ref("AnchorRange")),
|
|
211
|
+
"sample": nullable(ref("MatchedT0Sample")),
|
|
212
|
+
"administrative_cutoff": nullable(ref("Temporal")),
|
|
213
|
+
},
|
|
214
|
+
["unit", "anchor"],
|
|
215
|
+
)
|
|
216
|
+
return {
|
|
217
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
218
|
+
"$defs": definitions,
|
|
219
|
+
**obj({"schema_version": {"const": 1}, "design": ref("Design")}),
|
|
220
|
+
}
|
terrine/agent/policy.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Owner-only grants and resource limits for dataset operations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from terrine.data import Budget
|
|
8
|
+
from terrine.errors import RefusalSpec
|
|
9
|
+
from terrine.runtime.api import Executor
|
|
10
|
+
from terrine.runtime.saved_plan import DestinationRef, ModelReference, ModelResolver
|
|
11
|
+
from terrine.runtime.snapshot import SnapshotAuthority
|
|
12
|
+
|
|
13
|
+
__all__ = ["AgentPolicy"]
|
|
14
|
+
|
|
15
|
+
type Permission = Literal[
|
|
16
|
+
"inspect", "validate", "prepare", "write", "export", "preview", "read", "cancel"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
_DENIED = RefusalSpec("terrine.agent.permission_denied", "Owner policy denies this operation.")
|
|
20
|
+
_SCOPE = RefusalSpec("terrine.agent.scope_denied", "Requested resources are outside owner policy.")
|
|
21
|
+
_LIMIT = RefusalSpec("terrine.agent.request_limit", "Owner tool request limit has been reached.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def effective_budget(owner: Budget, requested: Budget | None) -> Budget:
|
|
25
|
+
"""Intersect optional nonnegative limits; None denotes no numeric bound."""
|
|
26
|
+
if not isinstance(owner, Budget) or (
|
|
27
|
+
requested is not None and not isinstance(requested, Budget)
|
|
28
|
+
):
|
|
29
|
+
raise TypeError("budgets must be Budget values")
|
|
30
|
+
if requested is None:
|
|
31
|
+
return owner
|
|
32
|
+
values = {}
|
|
33
|
+
for name in ("max_rows", "max_output_bytes", "max_scan_bytes"):
|
|
34
|
+
limits = [
|
|
35
|
+
value for value in (getattr(owner, name), getattr(requested, name)) if value is not None
|
|
36
|
+
]
|
|
37
|
+
values[name] = min(limits) if limits else None
|
|
38
|
+
return Budget(**values, strict_scan=owner.strict_scan or requested.strict_scan)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
42
|
+
class AgentPolicy:
|
|
43
|
+
"""Trusted configuration, never deserialized from a tool request.
|
|
44
|
+
|
|
45
|
+
Source identities are Catalog source names, destinations are exact owner
|
|
46
|
+
references, and catalogs use the owner ModelReference's catalog hash.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
inspect: bool = True
|
|
50
|
+
validate: bool = True
|
|
51
|
+
prepare: bool = False
|
|
52
|
+
write: bool = False
|
|
53
|
+
export: bool = False
|
|
54
|
+
preview: bool = False
|
|
55
|
+
read: bool = False
|
|
56
|
+
cancel: bool = False
|
|
57
|
+
allowed_sources: frozenset[str] = frozenset()
|
|
58
|
+
allowed_catalogs: frozenset[str] = frozenset()
|
|
59
|
+
allowed_destinations: frozenset[DestinationRef] = frozenset()
|
|
60
|
+
allowed_exports: frozenset[Path] = frozenset()
|
|
61
|
+
design_root: Path | None = None
|
|
62
|
+
budget: Budget = field(default_factory=Budget)
|
|
63
|
+
max_run_seconds: int = 300
|
|
64
|
+
max_concurrency: int = 1
|
|
65
|
+
max_requests: int = 100
|
|
66
|
+
max_preview_rows: int = 20
|
|
67
|
+
max_bounded_count: int = 1000
|
|
68
|
+
batch_cap: int = 100
|
|
69
|
+
preview_columns: frozenset[str] = frozenset()
|
|
70
|
+
redacted_columns: frozenset[str] = frozenset()
|
|
71
|
+
model_reference: ModelReference | None = None
|
|
72
|
+
resolver: ModelResolver | None = field(default=None, repr=False, compare=False)
|
|
73
|
+
snapshot_authority: SnapshotAuthority | None = field(default=None, repr=False, compare=False)
|
|
74
|
+
executor: Executor | None = field(default=None, repr=False, compare=False)
|
|
75
|
+
|
|
76
|
+
def __post_init__(self) -> None:
|
|
77
|
+
for name in (
|
|
78
|
+
"inspect",
|
|
79
|
+
"validate",
|
|
80
|
+
"prepare",
|
|
81
|
+
"write",
|
|
82
|
+
"export",
|
|
83
|
+
"preview",
|
|
84
|
+
"read",
|
|
85
|
+
"cancel",
|
|
86
|
+
):
|
|
87
|
+
if not isinstance(getattr(self, name), bool):
|
|
88
|
+
raise TypeError(f"{name} must be bool")
|
|
89
|
+
for name in (
|
|
90
|
+
"max_run_seconds",
|
|
91
|
+
"max_concurrency",
|
|
92
|
+
"max_requests",
|
|
93
|
+
"max_preview_rows",
|
|
94
|
+
"max_bounded_count",
|
|
95
|
+
"batch_cap",
|
|
96
|
+
):
|
|
97
|
+
value = getattr(self, name)
|
|
98
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
99
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
100
|
+
for name in ("allowed_sources", "allowed_catalogs", "preview_columns", "redacted_columns"):
|
|
101
|
+
value = frozenset(getattr(self, name))
|
|
102
|
+
if any(not isinstance(item, str) or not item for item in value):
|
|
103
|
+
raise TypeError(f"{name} must contain nonempty strings")
|
|
104
|
+
object.__setattr__(self, name, value)
|
|
105
|
+
destinations = frozenset(self.allowed_destinations)
|
|
106
|
+
if any(not isinstance(item, DestinationRef) for item in destinations):
|
|
107
|
+
raise TypeError("allowed_destinations must contain DestinationRef values")
|
|
108
|
+
object.__setattr__(self, "allowed_destinations", destinations)
|
|
109
|
+
exports = frozenset(Path(path).expanduser().resolve() for path in self.allowed_exports)
|
|
110
|
+
object.__setattr__(self, "allowed_exports", exports)
|
|
111
|
+
if not isinstance(self.budget, Budget):
|
|
112
|
+
raise TypeError("budget must be Budget")
|
|
113
|
+
if self.design_root is not None:
|
|
114
|
+
object.__setattr__(self, "design_root", Path(self.design_root).resolve())
|
|
115
|
+
|
|
116
|
+
def authorize(
|
|
117
|
+
self,
|
|
118
|
+
permission: Permission,
|
|
119
|
+
*,
|
|
120
|
+
sources: tuple[str, ...] = (),
|
|
121
|
+
catalog: str | None = None,
|
|
122
|
+
destination: DestinationRef | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
if permission not in {
|
|
125
|
+
"inspect",
|
|
126
|
+
"validate",
|
|
127
|
+
"prepare",
|
|
128
|
+
"write",
|
|
129
|
+
"export",
|
|
130
|
+
"preview",
|
|
131
|
+
"read",
|
|
132
|
+
"cancel",
|
|
133
|
+
} or not getattr(self, permission):
|
|
134
|
+
raise _DENIED.error(
|
|
135
|
+
operation=permission, route="Ask the owner to grant this operation."
|
|
136
|
+
)
|
|
137
|
+
if (
|
|
138
|
+
not set(sources) <= self.allowed_sources
|
|
139
|
+
or (catalog is not None and catalog not in self.allowed_catalogs)
|
|
140
|
+
or (destination is not None and destination not in self.allowed_destinations)
|
|
141
|
+
):
|
|
142
|
+
raise _SCOPE.error(
|
|
143
|
+
operation=permission, route="Use owner-approved sources and destinations."
|
|
144
|
+
)
|