trustline 0.1.0__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 (58) hide show
  1. trustline/__init__.py +18 -0
  2. trustline/cli/__init__.py +1 -0
  3. trustline/cli/audit.py +267 -0
  4. trustline/cli/main.py +37 -0
  5. trustline/cli/validate.py +115 -0
  6. trustline/compiler/__init__.py +1 -0
  7. trustline/compiler/audit_profile.py +79 -0
  8. trustline/compiler/cohort.py +59 -0
  9. trustline/compiler/funnel.py +100 -0
  10. trustline/compiler/paths.py +15 -0
  11. trustline/compiler/templates.py +66 -0
  12. trustline/config.py +135 -0
  13. trustline/contracts/__init__.py +1 -0
  14. trustline/contracts/audit_profile.py +98 -0
  15. trustline/contracts/loader.py +91 -0
  16. trustline/contracts/models.py +181 -0
  17. trustline/contracts/schemas.py +15 -0
  18. trustline/contracts/validator.py +208 -0
  19. trustline/exceptions.py +17 -0
  20. trustline/executors/__init__.py +5 -0
  21. trustline/executors/base.py +127 -0
  22. trustline/executors/duckdb.py +50 -0
  23. trustline/executors/snowflake.py +109 -0
  24. trustline/integrations/__init__.py +9 -0
  25. trustline/integrations/slack.py +84 -0
  26. trustline/reporters/__init__.py +8 -0
  27. trustline/reporters/brief.py +45 -0
  28. trustline/reporters/json_report.py +74 -0
  29. trustline/reporters/markdown.py +74 -0
  30. trustline/reporters/redact.py +42 -0
  31. trustline/reporters/rich_console.py +96 -0
  32. trustline/reporters/scoring.py +50 -0
  33. trustline/schemas/audit_profile.schema.json +52 -0
  34. trustline/schemas/cohort.schema.json +96 -0
  35. trustline/schemas/common.schema.json +47 -0
  36. trustline/schemas/funnel.schema.json +131 -0
  37. trustline/scorecard/__init__.py +6 -0
  38. trustline/scorecard/_common.py +41 -0
  39. trustline/scorecard/orchestrator.py +92 -0
  40. trustline/scorecard/phase1_pipeline.py +34 -0
  41. trustline/scorecard/phase2_funnel.py +33 -0
  42. trustline/scorecard/phase3_semantics.py +34 -0
  43. trustline/scorecard/phase4_training.py +33 -0
  44. trustline/scorecard/phase5_brief.py +110 -0
  45. trustline/scorecard/types.py +40 -0
  46. trustline/templates/sql/cohort_positive_rate.sql.j2 +2 -0
  47. trustline/templates/sql/cohort_source_parity.sql.j2 +5 -0
  48. trustline/templates/sql/crm_coverage_gap.sql.j2 +9 -0
  49. trustline/templates/sql/funnel_retention.sql.j2 +12 -0
  50. trustline/templates/sql/funnel_stage_count.sql.j2 +4 -0
  51. trustline/templates/sql/funnel_stage_join.sql.j2 +6 -0
  52. trustline/templates/sql/score_distribution.sql.j2 +8 -0
  53. trustline/templates/sql/source_swap_volume.sql.j2 +30 -0
  54. trustline-0.1.0.dist-info/METADATA +98 -0
  55. trustline-0.1.0.dist-info/RECORD +58 -0
  56. trustline-0.1.0.dist-info/WHEEL +4 -0
  57. trustline-0.1.0.dist-info/entry_points.txt +2 -0
  58. trustline-0.1.0.dist-info/licenses/LICENSE +202 -0
trustline/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Trustline — open-source trust layer for data products.
2
+
3
+ Public API (v0.2+):
4
+ validate: Validate contract YAML against JSON Schema
5
+ audit: Run five-phase trust scorecard
6
+
7
+ Current exports:
8
+ __version__: Package version string
9
+ """
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ try:
14
+ __version__ = version("trustline")
15
+ except PackageNotFoundError:
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ """CLI package for Trustline commands."""
trustline/cli/audit.py ADDED
@@ -0,0 +1,267 @@
1
+ """trustline audit — five-phase trust scorecard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Annotated, Literal
9
+
10
+ import typer
11
+
12
+ from trustline.compiler.audit_profile import compile_audit_profile_checks
13
+ from trustline.compiler.cohort import compile_cohort_checks
14
+ from trustline.compiler.funnel import compile_funnel_checks
15
+ from trustline.config import (
16
+ Profile,
17
+ load_profile,
18
+ resolve_duckdb_path,
19
+ resolve_profiles_path,
20
+ )
21
+ from trustline.contracts.audit_profile import AuditProfile, load_audit_profile
22
+ from trustline.contracts.loader import load_contracts_dir
23
+ from trustline.contracts.models import CohortManifest, FunnelContract
24
+ from trustline.exceptions import AuditError, ExecutorError, TrustlineError, ValidationError
25
+ from trustline.executors.base import CompiledCheck, Executor
26
+ from trustline.executors.duckdb import DuckDBExecutor
27
+ from trustline.reporters.brief import render_brief
28
+ from trustline.reporters.json_report import render_scorecard_json
29
+ from trustline.reporters.markdown import render_scorecard
30
+ from trustline.reporters.rich_console import render_scorecard_console
31
+ from trustline.scorecard._common import dialect_for_profile
32
+ from trustline.scorecard.orchestrator import run_full_audit
33
+ from trustline.scorecard.types import ScorecardResult
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ app = typer.Typer(help="Run five-phase trust scorecard audit.")
38
+ OutputFormat = Literal["text", "json", "both"]
39
+
40
+
41
+ def _default_audit_profile_path(contracts_dir: Path) -> Path:
42
+ return contracts_dir.parent / "audit_profile.yaml"
43
+
44
+
45
+ def _load_audit_profile(path: Path) -> AuditProfile | None:
46
+ if not path.is_file():
47
+ return None
48
+ return load_audit_profile(path)
49
+
50
+
51
+ def _compile_checks(
52
+ contracts: list[FunnelContract | CohortManifest],
53
+ audit_profile: AuditProfile | None,
54
+ profile: Profile,
55
+ ) -> list[CompiledCheck]:
56
+ dialect = dialect_for_profile(profile.target)
57
+ checks: list[CompiledCheck] = []
58
+ if audit_profile is not None:
59
+ checks.extend(compile_audit_profile_checks(audit_profile, profile, dialect))
60
+ for contract in contracts:
61
+ if isinstance(contract, FunnelContract):
62
+ checks.extend(compile_funnel_checks(contract, profile, dialect))
63
+ elif isinstance(contract, CohortManifest):
64
+ checks.extend(compile_cohort_checks(contract, profile, dialect))
65
+ return checks
66
+
67
+
68
+ def _build_executor(profile: Profile, profiles_path: Path) -> Executor:
69
+ if profile.target == "duckdb":
70
+ database = resolve_duckdb_path(profile, profiles_path)
71
+ if not database.is_file():
72
+ msg = f"DuckDB database not found: {database}"
73
+ raise TrustlineError(msg)
74
+ return DuckDBExecutor(database)
75
+ if profile.target == "snowflake":
76
+ from trustline.executors.snowflake import SnowflakeExecutor
77
+
78
+ return SnowflakeExecutor.from_env(profile)
79
+ msg = f"unsupported warehouse target: {profile.target!r}"
80
+ raise TrustlineError(msg)
81
+
82
+
83
+ def _print_text_summary(
84
+ result: ScorecardResult,
85
+ *,
86
+ title: str,
87
+ no_color: bool = False,
88
+ ) -> None:
89
+ render_scorecard_console(result, title=title, no_color=no_color)
90
+
91
+
92
+ def _write_reports(result: ScorecardResult, output_dir: Path, *, title: str) -> None:
93
+ output_dir.mkdir(parents=True, exist_ok=True)
94
+ (output_dir / "scorecard.md").write_text(
95
+ render_scorecard(result, title=title),
96
+ encoding="utf-8",
97
+ )
98
+ (output_dir / "scorecard.json").write_text(
99
+ json.dumps(render_scorecard_json(result), indent=2) + "\n",
100
+ encoding="utf-8",
101
+ )
102
+ (output_dir / "brief.md").write_text(render_brief(result), encoding="utf-8")
103
+
104
+
105
+ def _exit_code_for_verdict(verdict: str) -> int:
106
+ return 1 if verdict == "fail" else 0
107
+
108
+
109
+ def _maybe_notify_slack(
110
+ notify: str | None,
111
+ slack_webhook: str | None,
112
+ result: ScorecardResult,
113
+ *,
114
+ title: str,
115
+ ) -> None:
116
+ """Send Slack notification when configured and the audit failed."""
117
+ if notify is None:
118
+ return
119
+ if notify != "slack":
120
+ msg = f"unsupported notification channel: {notify!r}"
121
+ raise TrustlineError(msg)
122
+ if result.verdict != "fail":
123
+ return
124
+
125
+ from trustline.integrations.slack import notify_audit_failure, resolve_webhook_url
126
+
127
+ webhook_url = resolve_webhook_url(slack_webhook)
128
+ notify_audit_failure(webhook_url, result, title=title)
129
+
130
+
131
+ @app.callback(invoke_without_command=True)
132
+ def audit( # noqa: PLR0913
133
+ contracts: str = typer.Option(
134
+ "./contracts",
135
+ "--contracts",
136
+ "-c",
137
+ help="Directory containing contract YAML files.",
138
+ ),
139
+ target: str = typer.Option(
140
+ "duckdb",
141
+ "--target",
142
+ "-t",
143
+ help="Warehouse target: duckdb or snowflake.",
144
+ ),
145
+ profile_name: str = typer.Option(
146
+ "default",
147
+ "--profile",
148
+ "-p",
149
+ help="Profile name in profiles.yml.",
150
+ ),
151
+ audit_profile_path: str | None = typer.Option(
152
+ None,
153
+ "--audit-profile",
154
+ help="Path to audit_profile.yaml (default: sibling of contracts directory).",
155
+ ),
156
+ profiles_path: str | None = typer.Option(
157
+ None,
158
+ "--profiles",
159
+ help="Path to profiles.yml (default: ./profiles.yml or ACME example).",
160
+ ),
161
+ output_dir: str = typer.Option(
162
+ ".",
163
+ "--output-dir",
164
+ help="Directory for scorecard.md and scorecard.json.",
165
+ ),
166
+ output_format: Annotated[
167
+ OutputFormat,
168
+ typer.Option("--output", "-o", help="Output format: text, json, or both."),
169
+ ] = "text",
170
+ dry_run: bool = typer.Option(
171
+ False,
172
+ "--dry-run",
173
+ help="Compile checks only; do not execute SQL.",
174
+ ),
175
+ notify: str | None = typer.Option(
176
+ None,
177
+ "--notify",
178
+ help="Notification channel on failure (slack).",
179
+ ),
180
+ slack_webhook: str | None = typer.Option(
181
+ None,
182
+ "--slack-webhook",
183
+ envvar="SLACK_WEBHOOK_URL",
184
+ help="Slack incoming webhook URL.",
185
+ ),
186
+ no_color: bool = typer.Option(
187
+ False,
188
+ "--no-color",
189
+ help="Disable ANSI colors in terminal output.",
190
+ ),
191
+ ) -> None:
192
+ """Run the five-phase trust scorecard against contract YAML."""
193
+ try:
194
+ contracts_dir = Path(contracts)
195
+ if not contracts_dir.is_dir():
196
+ typer.echo(f"ERROR: contracts directory not found: {contracts_dir}", err=True)
197
+ raise typer.Exit(code=2)
198
+
199
+ resolved_profiles = resolve_profiles_path(
200
+ Path(profiles_path) if profiles_path is not None else None
201
+ )
202
+ if not resolved_profiles.is_file():
203
+ typer.echo(f"ERROR: profiles file not found: {resolved_profiles}", err=True)
204
+ raise typer.Exit(code=2)
205
+
206
+ profile = load_profile(profile_name, resolved_profiles)
207
+ if profile.target != target:
208
+ typer.echo(
209
+ f"ERROR: profile {profile_name!r} target {profile.target!r} "
210
+ f"does not match --target {target!r}",
211
+ err=True,
212
+ )
213
+ raise typer.Exit(code=2)
214
+
215
+ documents = load_contracts_dir(contracts_dir)
216
+ if not documents:
217
+ typer.echo("ERROR: no contract files found.", err=True)
218
+ raise typer.Exit(code=2)
219
+
220
+ audit_path = (
221
+ Path(audit_profile_path)
222
+ if audit_profile_path is not None
223
+ else _default_audit_profile_path(contracts_dir)
224
+ )
225
+ audit_profile = _load_audit_profile(audit_path)
226
+
227
+ if dry_run:
228
+ compiled = _compile_checks(documents, audit_profile, profile)
229
+ typer.echo(f"Compiled {len(compiled)} checks (dry run).")
230
+ raise typer.Exit(code=0)
231
+
232
+ executor = _build_executor(profile, resolved_profiles)
233
+ try:
234
+ result = run_full_audit(documents, audit_profile, executor, profile)
235
+ finally:
236
+ if hasattr(executor, "close"):
237
+ executor.close()
238
+
239
+ title = contracts_dir.parent.name.replace("_", " ").title()
240
+ reports_dir = Path(output_dir)
241
+ _write_reports(result, reports_dir, title=title)
242
+
243
+ if output_format in {"text", "both"}:
244
+ _print_text_summary(result, title=title, no_color=no_color)
245
+ typer.echo(f"\nReports written to {reports_dir.resolve()}/")
246
+ if output_format in {"json", "both"}:
247
+ typer.echo(json.dumps(render_scorecard_json(result), indent=2))
248
+
249
+ _maybe_notify_slack(notify, slack_webhook, result, title=title)
250
+
251
+ raise typer.Exit(code=_exit_code_for_verdict(result.verdict))
252
+ except typer.Exit:
253
+ raise
254
+ except FileNotFoundError as exc:
255
+ typer.echo(f"ERROR: {exc}", err=True)
256
+ raise typer.Exit(code=2) from exc
257
+ except (ValidationError, TrustlineError, AuditError) as exc:
258
+ typer.echo(f"ERROR: {exc}", err=True)
259
+ raise typer.Exit(code=2) from exc
260
+ except ExecutorError as exc:
261
+ logger.exception("audit executor failure")
262
+ typer.echo(f"ERROR: {exc}", err=True)
263
+ raise typer.Exit(code=3) from exc
264
+ except Exception as exc:
265
+ logger.exception("unexpected audit failure")
266
+ typer.echo(f"ERROR: internal error: {exc}", err=True)
267
+ raise typer.Exit(code=3) from exc
trustline/cli/main.py ADDED
@@ -0,0 +1,37 @@
1
+ """Trustline CLI entrypoint."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ import typer
6
+
7
+ from trustline.cli import audit, validate
8
+
9
+ app = typer.Typer(
10
+ name="trustline",
11
+ help="Open-source trust layer for data products.",
12
+ no_args_is_help=True,
13
+ )
14
+
15
+
16
+ def _version_callback(value: bool) -> None:
17
+ if value:
18
+ typer.echo(version("trustline"))
19
+ raise typer.Exit()
20
+
21
+
22
+ @app.callback()
23
+ def main(
24
+ version_flag: bool = typer.Option(
25
+ False,
26
+ "--version",
27
+ "-V",
28
+ help="Show version and exit.",
29
+ callback=_version_callback,
30
+ is_eager=True,
31
+ ),
32
+ ) -> None:
33
+ """Trustline — machine-checkable contracts across ETL, ML, and delivery seams."""
34
+
35
+
36
+ app.add_typer(validate.app, name="validate")
37
+ app.add_typer(audit.app, name="audit")
@@ -0,0 +1,115 @@
1
+ """trustline validate — contract YAML validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from trustline.contracts.validator import (
11
+ ValidationSummary,
12
+ validate_contract_file,
13
+ validate_contracts_dir,
14
+ )
15
+ from trustline.exceptions import TrustlineError, ValidationError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ app = typer.Typer(help="Validate contract YAML against JSON Schema.")
20
+
21
+
22
+ def _print_summary(summary: ValidationSummary) -> None:
23
+ """Print per-file validation results."""
24
+ typer.echo(f"Validating {summary.total} contracts...\n")
25
+ for result in summary.results:
26
+ status = "PASS" if result.passed else "FAIL"
27
+ kind = result.kind or "Unknown"
28
+ typer.echo(f" {result.path.name:<40} {kind:<20} {status}")
29
+ if not result.passed:
30
+ for error in result.errors:
31
+ typer.echo(error, err=True)
32
+
33
+
34
+ def _summary_from_file(path: Path) -> ValidationSummary:
35
+ """Build a validation summary for a single file."""
36
+ result = validate_contract_file(path)
37
+ passed = 1 if result.passed else 0
38
+ return ValidationSummary(
39
+ total=1,
40
+ passed=passed,
41
+ failed=1 - passed,
42
+ results=(result,),
43
+ )
44
+
45
+
46
+ def _exit_code_for_summary(summary: ValidationSummary) -> int:
47
+ """Map validation summary to CLI exit code."""
48
+ if summary.failed:
49
+ return 1
50
+ return 0
51
+
52
+
53
+ @app.callback(invoke_without_command=True)
54
+ def validate(
55
+ contracts: str = typer.Option(
56
+ "./contracts",
57
+ "--contracts",
58
+ "-c",
59
+ help="Directory containing contract YAML files.",
60
+ ),
61
+ file: str | None = typer.Option(
62
+ None,
63
+ "--file",
64
+ "-f",
65
+ help="Validate a single contract file instead of a directory.",
66
+ ),
67
+ strict: bool = typer.Option(
68
+ False,
69
+ "--strict",
70
+ help="Treat warnings as errors (reserved for future use).",
71
+ ),
72
+ ) -> None:
73
+ """Validate contract YAML files against JSON Schema."""
74
+ del strict # reserved for v0.1; schema errors already fail validation
75
+
76
+ try:
77
+ if file is not None:
78
+ path = Path(file)
79
+ if not path.is_file():
80
+ typer.echo(f"ERROR: contract file not found: {path}", err=True)
81
+ raise typer.Exit(code=2)
82
+ summary = _summary_from_file(path)
83
+ else:
84
+ directory = Path(contracts)
85
+ if not directory.is_dir():
86
+ typer.echo(f"ERROR: contracts directory not found: {directory}", err=True)
87
+ raise typer.Exit(code=2)
88
+ summary = validate_contracts_dir(directory)
89
+
90
+ _print_summary(summary)
91
+ if summary.total == 0:
92
+ typer.echo("\nNo contract files found.")
93
+ raise typer.Exit(code=2)
94
+
95
+ if summary.failed:
96
+ typer.echo("\nValidation failed.")
97
+ raise typer.Exit(code=_exit_code_for_summary(summary))
98
+
99
+ typer.echo("\nAll contracts valid.")
100
+ except typer.Exit:
101
+ raise
102
+ except FileNotFoundError as exc:
103
+ typer.echo(f"ERROR: {exc}", err=True)
104
+ raise typer.Exit(code=2) from exc
105
+ except ValidationError as exc:
106
+ typer.echo(f"ERROR: {exc}", err=True)
107
+ raise typer.Exit(code=1) from exc
108
+ except TrustlineError as exc:
109
+ logger.exception("validate command failed")
110
+ typer.echo(f"ERROR: {exc}", err=True)
111
+ raise typer.Exit(code=3) from exc
112
+ except Exception as exc:
113
+ logger.exception("unexpected validate failure")
114
+ typer.echo(f"ERROR: internal error: {exc}", err=True)
115
+ raise typer.Exit(code=3) from exc
@@ -0,0 +1 @@
1
+ """Contract-to-SQL check compilation."""
@@ -0,0 +1,79 @@
1
+ """Compile audit profile documents into SQL checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from trustline.compiler.templates import render_template, resolve_refs
6
+ from trustline.config import Profile
7
+ from trustline.contracts.audit_profile import AuditProfile
8
+ from trustline.executors.base import CheckExpectation, CompiledCheck
9
+
10
+
11
+ def compile_audit_profile_checks(
12
+ profile_doc: AuditProfile,
13
+ profile: Profile,
14
+ dialect: str,
15
+ ) -> list[CompiledCheck]:
16
+ """Compile Phase 1 and Phase 3 checks from an audit profile."""
17
+ checks: list[CompiledCheck] = []
18
+
19
+ sync_table = resolve_refs(profile_doc.crm_coverage.sync_table, profile)
20
+ mirror_table = resolve_refs(profile_doc.crm_coverage.mirror_table, profile)
21
+ checks.append(
22
+ CompiledCheck(
23
+ check_id="audit.crm_coverage",
24
+ phase=1,
25
+ sql=render_template(
26
+ "crm_coverage_gap.sql.j2",
27
+ {"sync_table": sync_table, "mirror_table": mirror_table},
28
+ dialect,
29
+ ),
30
+ expect=CheckExpectation(
31
+ kind="min_sync_pct",
32
+ value=profile_doc.crm_coverage.expect_sync_pct,
33
+ ),
34
+ evidence_key="crm_coverage",
35
+ )
36
+ )
37
+
38
+ swap_table = resolve_refs(profile_doc.source_swap.table, profile)
39
+ migration = profile_doc.source_swap.migration
40
+ checks.append(
41
+ CompiledCheck(
42
+ check_id="audit.source_swap_volume",
43
+ phase=3,
44
+ sql=render_template(
45
+ "source_swap_volume.sql.j2",
46
+ {
47
+ "table_ref": swap_table,
48
+ "from_source": migration.from_source,
49
+ "to_source": migration.to_source,
50
+ "cutover_date": migration.cutover_date.isoformat(),
51
+ },
52
+ dialect,
53
+ ),
54
+ expect=CheckExpectation(
55
+ kind="max_drift_pct",
56
+ value=profile_doc.source_swap.volume_threshold_pct,
57
+ tolerance=2.0,
58
+ ),
59
+ evidence_key="source_swap",
60
+ )
61
+ )
62
+
63
+ if profile_doc.score_distribution is not None:
64
+ table_ref = resolve_refs(profile_doc.score_distribution.table, profile)
65
+ checks.append(
66
+ CompiledCheck(
67
+ check_id="audit.score_distribution",
68
+ phase=3,
69
+ sql=render_template(
70
+ "score_distribution.sql.j2",
71
+ {"table_ref": table_ref},
72
+ dialect,
73
+ ),
74
+ expect=CheckExpectation(kind="min_count", value=1.0),
75
+ evidence_key="score_distribution",
76
+ )
77
+ )
78
+
79
+ return checks
@@ -0,0 +1,59 @@
1
+ """Compile CohortManifest documents into SQL checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from trustline.compiler.templates import render_template, resolve_refs
6
+ from trustline.config import Profile
7
+ from trustline.contracts.models import CohortManifest
8
+ from trustline.executors.base import CheckExpectation, CompiledCheck
9
+
10
+
11
+ def compile_cohort_checks(
12
+ contract: CohortManifest,
13
+ profile: Profile,
14
+ dialect: str,
15
+ ) -> list[CompiledCheck]:
16
+ """Compile cohort source parity and positive rate checks."""
17
+ training_table = resolve_refs(contract.spec.sources.training, profile)
18
+ scoring_table = resolve_refs(contract.spec.sources.scoring, profile)
19
+ contract_name = contract.metadata.name
20
+
21
+ source_parity_sql = render_template(
22
+ "cohort_source_parity.sql.j2",
23
+ {
24
+ "training_table": training_table,
25
+ "scoring_table": scoring_table,
26
+ },
27
+ dialect,
28
+ )
29
+
30
+ label_sql = contract.spec.label.sql or "1"
31
+ positive_rate_sql = render_template(
32
+ "cohort_positive_rate.sql.j2",
33
+ {
34
+ "label_sql": label_sql,
35
+ "training_table": training_table,
36
+ },
37
+ dialect,
38
+ )
39
+
40
+ return [
41
+ CompiledCheck(
42
+ check_id=f"cohort.source_parity.{contract_name}",
43
+ phase=4,
44
+ sql=source_parity_sql,
45
+ expect=CheckExpectation(kind="source_parity", value=1.0),
46
+ evidence_key="sources",
47
+ ),
48
+ CompiledCheck(
49
+ check_id=f"cohort.positive_rate.{contract_name}",
50
+ phase=4,
51
+ sql=positive_rate_sql,
52
+ expect=CheckExpectation(
53
+ kind="positive_rate",
54
+ value=contract.spec.expected_positive_rate,
55
+ tolerance=contract.spec.positive_rate_tolerance,
56
+ ),
57
+ evidence_key="positive_rate",
58
+ ),
59
+ ]
@@ -0,0 +1,100 @@
1
+ """Compile FunnelContract documents into SQL checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from trustline.compiler.templates import render_template, resolve_refs
6
+ from trustline.config import Profile
7
+ from trustline.contracts.models import FunnelContract, FunnelStage
8
+ from trustline.exceptions import AuditError
9
+ from trustline.executors.base import CheckExpectation, CompiledCheck
10
+
11
+
12
+ def _stage_subquery(
13
+ stage: FunnelStage,
14
+ stage_queries: dict[str, str],
15
+ profile: Profile,
16
+ dialect: str,
17
+ ) -> str:
18
+ """Build the SQL subquery that materializes a funnel stage."""
19
+ if stage.sql is not None:
20
+ return resolve_refs(stage.sql, profile)
21
+
22
+ if stage.from_stage is None or stage.join is None:
23
+ msg = f"stage {stage.name!r} is missing sql or join configuration"
24
+ raise AuditError(msg)
25
+
26
+ return render_template(
27
+ "funnel_stage_join.sql.j2",
28
+ {
29
+ "prior_stage_sql": stage_queries[stage.from_stage],
30
+ "join_table": resolve_refs(stage.join.table, profile),
31
+ "join_type": stage.join.type.upper(),
32
+ "join_on": stage.join.on,
33
+ },
34
+ dialect,
35
+ )
36
+
37
+
38
+ def _build_stage_queries(
39
+ contract: FunnelContract, profile: Profile, dialect: str
40
+ ) -> dict[str, str]:
41
+ """Compile all stage subqueries in funnel order."""
42
+ queries: dict[str, str] = {}
43
+ for stage in contract.spec.stages:
44
+ queries[stage.name] = _stage_subquery(stage, queries, profile, dialect)
45
+ return queries
46
+
47
+
48
+ def compile_funnel_checks(
49
+ contract: FunnelContract,
50
+ profile: Profile,
51
+ dialect: str,
52
+ ) -> list[CompiledCheck]:
53
+ """Compile funnel stage count and retention checks."""
54
+ checks: list[CompiledCheck] = []
55
+ stage_queries = _build_stage_queries(contract, profile, dialect)
56
+
57
+ for index, stage in enumerate(contract.spec.stages):
58
+ stage_sql = stage_queries[stage.name]
59
+ contract_name = contract.metadata.name
60
+
61
+ if stage.expect_min_count is not None:
62
+ sql = render_template(
63
+ "funnel_stage_count.sql.j2",
64
+ {"stage_sql": stage_sql, "stage_name": stage.name},
65
+ dialect,
66
+ )
67
+ checks.append(
68
+ CompiledCheck(
69
+ check_id=f"funnel.count.{contract_name}.{stage.name}",
70
+ phase=2,
71
+ sql=sql,
72
+ expect=CheckExpectation(kind="min_count", value=float(stage.expect_min_count)),
73
+ evidence_key=stage.name,
74
+ )
75
+ )
76
+
77
+ if stage.expect_retention_pct is not None and index > 0:
78
+ prior_stage = contract.spec.stages[index - 1]
79
+ sql = render_template(
80
+ "funnel_retention.sql.j2",
81
+ {
82
+ "stage_sql": stage_sql,
83
+ "prior_stage_sql": stage_queries[prior_stage.name],
84
+ },
85
+ dialect,
86
+ )
87
+ checks.append(
88
+ CompiledCheck(
89
+ check_id=f"funnel.retention.{contract_name}.{stage.name}",
90
+ phase=2,
91
+ sql=sql,
92
+ expect=CheckExpectation(
93
+ kind="min_retention_pct",
94
+ value=float(stage.expect_retention_pct),
95
+ ),
96
+ evidence_key=stage.name,
97
+ )
98
+ )
99
+
100
+ return checks