semantic-trust 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.
- semantic_trust-0.1.0.dist-info/METADATA +17 -0
- semantic_trust-0.1.0.dist-info/RECORD +21 -0
- semantic_trust-0.1.0.dist-info/WHEEL +5 -0
- semantic_trust-0.1.0.dist-info/entry_points.txt +3 -0
- semantic_trust-0.1.0.dist-info/licenses/LICENSE +21 -0
- semantic_trust-0.1.0.dist-info/top_level.txt +1 -0
- trust/__init__.py +0 -0
- trust/cli.py +119 -0
- trust/compile.py +28 -0
- trust/config.py +37 -0
- trust/joinability.py +216 -0
- trust/judgment.py +102 -0
- trust/manifest_loader.py +164 -0
- trust/manifest_scaffold.py +83 -0
- trust/mcp_server.py +207 -0
- trust/normalized.py +26 -0
- trust/ownership.py +97 -0
- trust/report.py +113 -0
- trust/scorer.py +319 -0
- trust/spec_detect.py +39 -0
- trust/uniqueness.py +62 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: semantic-trust
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Score and certify dbt Semantic Layer models — trust bands, gates, and dbt-parse certification.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: mcp>=1.0
|
|
8
|
+
Provides-Extra: ci
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == "ci"
|
|
10
|
+
Requires-Dist: hypothesis>=6.0; extra == "ci"
|
|
11
|
+
Requires-Dist: pyyaml>=6.0; extra == "ci"
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
14
|
+
Requires-Dist: hypothesis>=6.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pyyaml>=6.0; extra == "dev"
|
|
16
|
+
Requires-Dist: dbt-metricflow<0.14,>=0.13; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
semantic_trust-0.1.0.dist-info/licenses/LICENSE,sha256=1r0o_UhrOmuTUzlbt5iEVop1Fv5ZO8RjvYGMNjX4oWo,1068
|
|
2
|
+
trust/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
trust/cli.py,sha256=-4aJu9cjC6OJVB4K2iX6drH_UpdG5lh9Azu3gW-WtL0,4953
|
|
4
|
+
trust/compile.py,sha256=XbwwQI2OHyY46yybq9ZIXvDDh6_2H5Q4Yv4TNf7Kb7I,1060
|
|
5
|
+
trust/config.py,sha256=O2dIO9FdRy05Zv6jhxRoKZLumpaBDmdrz1R7zw5yVT4,1186
|
|
6
|
+
trust/joinability.py,sha256=HAW5eKfiC5tQKMBI4Zelh8hC_yaGdwyctNqJtKfuvgM,8471
|
|
7
|
+
trust/judgment.py,sha256=dIzYuuR54YCSgsH_1XjgQvpr1e7MSm8xo-eSHGZrD6Q,3899
|
|
8
|
+
trust/manifest_loader.py,sha256=gVu2X5iEAkgC8WCznhpSovFg4eA43aNqAS04cbRF6wg,6474
|
|
9
|
+
trust/manifest_scaffold.py,sha256=JTdCIDvkrb2QF5E29hKpIfrdmRollheeET7bOqoB404,3783
|
|
10
|
+
trust/mcp_server.py,sha256=rtopQD_TWnD-SjiXbBKFsDWC5BZvnAAHOx5EQSdPnik,9177
|
|
11
|
+
trust/normalized.py,sha256=OFNGR39W5yQtETFvjahahK8Gj-j2k8s8Q3W_yT8S5kU,869
|
|
12
|
+
trust/ownership.py,sha256=4SLmFQUAtJF9A-YhY7ZMJ6lewXOL_DuMwogMCMApAIE,3423
|
|
13
|
+
trust/report.py,sha256=hii2CQbNOPmhJcxF1LWaQF5pVzxonYy40KOwMnlMt6A,4329
|
|
14
|
+
trust/scorer.py,sha256=71cJFX0O7eC-LdsEesDDWEP1BZBTuMNVtDrCG_StwIU,11521
|
|
15
|
+
trust/spec_detect.py,sha256=lm2eRLsJZ3PkK8DcTqALUbEKpXvf3ewPCjOgVcohcbc,1375
|
|
16
|
+
trust/uniqueness.py,sha256=PZGyuvCj0iWlilz6Xit2MOmD3gQAXsvsfHDxmD6xHCU,2732
|
|
17
|
+
semantic_trust-0.1.0.dist-info/METADATA,sha256=DUxkvQVRmb-b42lwB1o387PTy9Ssyafs5ilSMbjEBLs,616
|
|
18
|
+
semantic_trust-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
19
|
+
semantic_trust-0.1.0.dist-info/entry_points.txt,sha256=_26KQWYsv1EnYiK0aBpCZIAL4Md13_dwmzDW8HWm-88,97
|
|
20
|
+
semantic_trust-0.1.0.dist-info/top_level.txt,sha256=NzvF6jjub_MhDLfrB0muRk25AxCu4iFX0dzIHx9d1uU,6
|
|
21
|
+
semantic_trust-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ravish Jain
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
trust
|
trust/__init__.py
ADDED
|
File without changes
|
trust/cli.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local dev CLI for the trust scorer. NOT the skill-facing interface in production —
|
|
3
|
+
skills consume the MCP server (trust/mcp_server.py, AD-1).
|
|
4
|
+
|
|
5
|
+
Retained for: manual spot-checks during development, smoke tests, Task 3 wiring smoke.
|
|
6
|
+
"""
|
|
7
|
+
import json, sys
|
|
8
|
+
from trust.manifest_loader import load_models, load_metrics
|
|
9
|
+
from trust.uniqueness import find_collisions
|
|
10
|
+
from trust.joinability import check_joinability
|
|
11
|
+
from trust.scorer import score_model
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _metrics_for_model(sm, all_metrics):
|
|
15
|
+
"""
|
|
16
|
+
Return metrics owned by this semantic model.
|
|
17
|
+
|
|
18
|
+
Attribution is exact via each metric's owner_model field, stamped at load time:
|
|
19
|
+
- Latest spec: STRUCTURAL — a simple metric is owned by the model it is nested
|
|
20
|
+
under (semantic_model.metrics). No column/substring matching.
|
|
21
|
+
- Legacy spec: the semantic_model that DEFINES the measure the metric references
|
|
22
|
+
(type_params.measure → SM).
|
|
23
|
+
Cross-model metrics (latest derived/ratio) have owner_model=None and match no model.
|
|
24
|
+
"""
|
|
25
|
+
return [m for m in all_metrics if m.owner_model == sm.name]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _orphan_collision_warnings(all_metrics: list, collisions: list) -> list[str]:
|
|
29
|
+
"""
|
|
30
|
+
Build warning strings for collisions that involve at least one unattributed
|
|
31
|
+
(owner_model=None) metric.
|
|
32
|
+
|
|
33
|
+
Orphan metrics are excluded from any single model's gate, so a collision among
|
|
34
|
+
them would otherwise pass clean-and-silent. This helper surfaces BOTH formula and
|
|
35
|
+
name collisions that involve an orphan so they are visible in the report.
|
|
36
|
+
|
|
37
|
+
Note: a name collision between two orphan metrics is unreachable via real dbt —
|
|
38
|
+
dbt enforces unique metric names at parse time. This path is retained as
|
|
39
|
+
defense-in-depth and is unit-tested directly in tests/test_cli.py.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
all_metrics: full list of NormalizedMetric for the project.
|
|
43
|
+
collisions: output of find_collisions(all_metrics), each a dict with keys
|
|
44
|
+
{kind, a, b, files}.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of human-readable warning strings (may be empty).
|
|
48
|
+
"""
|
|
49
|
+
unattributed_names = {m.name for m in all_metrics if m.owner_model is None}
|
|
50
|
+
warnings: list[str] = []
|
|
51
|
+
for c in collisions:
|
|
52
|
+
kind = c.get("kind")
|
|
53
|
+
if kind not in ("formula", "name"):
|
|
54
|
+
continue
|
|
55
|
+
label = "formula collision" if kind == "formula" else "name collision"
|
|
56
|
+
if c.get("a") in unattributed_names:
|
|
57
|
+
warnings.append(
|
|
58
|
+
f"unattributed metric '{c['a']}' has {label} with '{c['b']}'"
|
|
59
|
+
)
|
|
60
|
+
elif c.get("b") in unattributed_names:
|
|
61
|
+
warnings.append(
|
|
62
|
+
f"unattributed metric '{c['b']}' has {label} with '{c['a']}'"
|
|
63
|
+
)
|
|
64
|
+
return warnings
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_report_object(project_dir: str, model_name: str):
|
|
68
|
+
"""
|
|
69
|
+
Build the fully-populated ModelReport object for *model_name*.
|
|
70
|
+
|
|
71
|
+
Returns a ModelReport on success, or a dict {"model": ..., "error": ...} if the
|
|
72
|
+
semantic model is not found. Callers that need the object for apply_judgment must
|
|
73
|
+
check for the error dict first (isinstance check on dict).
|
|
74
|
+
|
|
75
|
+
This is the shared implementation beneath build_report (dict) and the MCP
|
|
76
|
+
validate_semantic_model handler (object). Back-compat: build_report's dict
|
|
77
|
+
contract is preserved by calling .to_dict() on the result.
|
|
78
|
+
"""
|
|
79
|
+
from trust.report import ModelReport # avoid circular at module level
|
|
80
|
+
models = load_models(project_dir)
|
|
81
|
+
sm = next((m for m in models if m.name == model_name), None)
|
|
82
|
+
if sm is None:
|
|
83
|
+
return {"model": model_name, "error": "semantic model not found in project"}
|
|
84
|
+
all_metrics = load_metrics(project_dir)
|
|
85
|
+
collisions = find_collisions(all_metrics)
|
|
86
|
+
model_metrics = _metrics_for_model(sm, all_metrics)
|
|
87
|
+
# Joinability needs ALL models — compute cross-model issues then filter to this model.
|
|
88
|
+
all_j_issues = check_joinability(models)
|
|
89
|
+
model_j_issues = [i for i in all_j_issues if i.location == sm.source_file]
|
|
90
|
+
rep = score_model(
|
|
91
|
+
sm, model_metrics, collisions,
|
|
92
|
+
joinability_issues=model_j_issues,
|
|
93
|
+
project_dir=project_dir,
|
|
94
|
+
)
|
|
95
|
+
unattributed = [m for m in all_metrics if m.owner_model is None]
|
|
96
|
+
rep.warnings = _orphan_collision_warnings(all_metrics, collisions)
|
|
97
|
+
rep.unattributed_metrics = len(unattributed)
|
|
98
|
+
return rep
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_report(project_dir: str, model_name: str) -> dict:
|
|
102
|
+
"""Return the deterministic trust report as a plain dict (back-compat public API)."""
|
|
103
|
+
result = build_report_object(project_dir, model_name)
|
|
104
|
+
if isinstance(result, dict):
|
|
105
|
+
# error path — already a dict
|
|
106
|
+
return result
|
|
107
|
+
return result.to_dict()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main_cli():
|
|
111
|
+
if len(sys.argv) < 3:
|
|
112
|
+
print("usage: python -m trust.cli <project_dir> <model_name>", file=sys.stderr)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
project_dir, model_name = sys.argv[1], sys.argv[2]
|
|
115
|
+
print(json.dumps(build_report(project_dir, model_name), indent=2))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
main_cli()
|
trust/compile.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Production-path helper: run the user's own `dbt parse` to (re)generate
|
|
2
|
+
target/semantic_manifest.json, then the engine reads it. Version-agnostic:
|
|
3
|
+
`dbt parse` works for both legacy (1.6-1.11) and latest (1.12+) specs.
|
|
4
|
+
(mf validate-configs is legacy-only — dbt-metricflow pins dbt-core <1.12 — so
|
|
5
|
+
dbt parse, not mf, is the universal compile gate.)"""
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compile_manifest(project_dir: str, profiles_dir: str | None = None) -> dict:
|
|
11
|
+
env = dict(os.environ)
|
|
12
|
+
env["DBT_PROJECT_DIR"] = project_dir
|
|
13
|
+
if profiles_dir:
|
|
14
|
+
env["DBT_PROFILES_DIR"] = profiles_dir
|
|
15
|
+
proc = subprocess.run(
|
|
16
|
+
["dbt", "parse"],
|
|
17
|
+
cwd=project_dir, env=env,
|
|
18
|
+
capture_output=True, text=True,
|
|
19
|
+
)
|
|
20
|
+
manifest_path = os.path.join(project_dir, "target", "semantic_manifest.json")
|
|
21
|
+
ok = proc.returncode == 0 and os.path.exists(manifest_path)
|
|
22
|
+
return {
|
|
23
|
+
"ok": ok,
|
|
24
|
+
"returncode": proc.returncode,
|
|
25
|
+
"stdout": proc.stdout,
|
|
26
|
+
"stderr": proc.stderr,
|
|
27
|
+
"manifest_path": manifest_path,
|
|
28
|
+
}
|
trust/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
trust/config.py — load optional project-level config from .semantic-trust.json.
|
|
3
|
+
|
|
4
|
+
Safe default: if file is missing or key is absent → empty list.
|
|
5
|
+
An empty approved_email_domains list means NO domain check is applied (not a failure).
|
|
6
|
+
|
|
7
|
+
Config file format (.semantic-trust.json):
|
|
8
|
+
{"approved_email_domains": ["example.com", "corp.io"]}
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_config(project_dir: str) -> dict:
|
|
15
|
+
"""
|
|
16
|
+
Read .semantic-trust.json from project_dir (if it exists).
|
|
17
|
+
|
|
18
|
+
Returns a dict with at minimum:
|
|
19
|
+
{"approved_email_domains": [...]}
|
|
20
|
+
|
|
21
|
+
SAFE DEFAULT: missing file, empty file, or absent key → empty list.
|
|
22
|
+
Never hardcodes any domain — the allowlist comes exclusively from the file.
|
|
23
|
+
"""
|
|
24
|
+
config_path = os.path.join(project_dir, ".semantic-trust.json")
|
|
25
|
+
raw: dict = {}
|
|
26
|
+
if os.path.isfile(config_path):
|
|
27
|
+
with open(config_path, "r") as fh:
|
|
28
|
+
try:
|
|
29
|
+
loaded = json.load(fh)
|
|
30
|
+
if isinstance(loaded, dict):
|
|
31
|
+
raw = loaded
|
|
32
|
+
except json.JSONDecodeError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
"approved_email_domains": raw.get("approved_email_domains") or [],
|
|
37
|
+
}
|
trust/joinability.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-model joinability gate [J] (Task 3.1b).
|
|
3
|
+
|
|
4
|
+
Produces Issue(severity="warning", provenance="deterministic") for:
|
|
5
|
+
- joinability_orphan : an entity name appears on exactly one model
|
|
6
|
+
(cannot participate in a join).
|
|
7
|
+
- joinability_parity : the same *conceptual* join partner resolves to
|
|
8
|
+
different entity names across models — detected
|
|
9
|
+
when entity names share a common stem but differ
|
|
10
|
+
in suffix (e.g. "customer" vs "customer_id") while
|
|
11
|
+
being used as join-compatible types (primary/foreign).
|
|
12
|
+
|
|
13
|
+
Orphan detection strategy
|
|
14
|
+
--------------------------
|
|
15
|
+
MetricFlow ships `CommonEntitysRule` in
|
|
16
|
+
metricflow_semantic_interfaces.validations.common_entities
|
|
17
|
+
This rule does the same orphan check but requires constructing MetricFlow's
|
|
18
|
+
internal SemanticManifest/SemanticModel objects. We confirm the import
|
|
19
|
+
succeeds (proving the rule exists and the dependency is present) and then
|
|
20
|
+
implement the equivalent logic over NormalizedModel directly — avoiding the
|
|
21
|
+
overhead of building MetricFlow's internal object graph just to call one rule.
|
|
22
|
+
`CommonEntitysRule` is a NON-DEFAULT MetricFlow rule: it is NOT included in
|
|
23
|
+
the validator's DEFAULT_RULES set; callers must add it explicitly.
|
|
24
|
+
|
|
25
|
+
Parity detection strategy
|
|
26
|
+
--------------------------
|
|
27
|
+
MetricFlow has no built-in cross-model name-parity check. We implement it:
|
|
28
|
+
1. Normalise each entity name to a *stem* (strip trailing `_id`, `_key`).
|
|
29
|
+
2. Group entity names by stem across all models.
|
|
30
|
+
3. When a stem maps to more than one distinct entity name, the names
|
|
31
|
+
are mismatched join-partner candidates — flag joinability_parity.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from collections import defaultdict
|
|
36
|
+
from typing import TYPE_CHECKING
|
|
37
|
+
|
|
38
|
+
from trust.report import Issue
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from trust.normalized import NormalizedModel
|
|
42
|
+
|
|
43
|
+
# --- MetricFlow import probe (non-default rule; light dependency) ---
|
|
44
|
+
# CommonEntitysRule is a non-default MetricFlow validation rule. We confirm
|
|
45
|
+
# the import works so users know the dependency is present, then fall back to
|
|
46
|
+
# our own equivalent scan over NormalizedModel.entities.
|
|
47
|
+
try:
|
|
48
|
+
from metricflow_semantic_interfaces.validations.common_entities import ( # noqa: F401
|
|
49
|
+
CommonEntitysRule,
|
|
50
|
+
)
|
|
51
|
+
_MF_COMMON_ENTITIES_AVAILABLE = True
|
|
52
|
+
except ImportError:
|
|
53
|
+
_MF_COMMON_ENTITIES_AVAILABLE = False
|
|
54
|
+
|
|
55
|
+
# Types that represent "one end" of a join (primary key side).
|
|
56
|
+
_PRIMARY_TYPES = {"primary", "natural"}
|
|
57
|
+
# Types that reference the primary side (foreign key side).
|
|
58
|
+
_FOREIGN_TYPES = {"foreign"}
|
|
59
|
+
# Both sides together — entities of other types (e.g. "unique") are skipped.
|
|
60
|
+
_JOIN_TYPES = _PRIMARY_TYPES | _FOREIGN_TYPES
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _stem(name: str) -> str:
|
|
64
|
+
"""Normalise an entity name to a stem by stripping common id-suffix patterns."""
|
|
65
|
+
for suffix in ("_id", "_key", "_ref"):
|
|
66
|
+
if name.endswith(suffix) and len(name) > len(suffix):
|
|
67
|
+
return name[: -len(suffix)]
|
|
68
|
+
return name
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _orphan_issues(models: list) -> list[Issue]:
|
|
72
|
+
"""
|
|
73
|
+
Flag entity names that appear on exactly one model.
|
|
74
|
+
|
|
75
|
+
Logic mirrors CommonEntitysRule._map_semantic_model_entities but operates
|
|
76
|
+
on NormalizedModel.entities dicts ({name, type}) instead of MetricFlow's
|
|
77
|
+
internal Entity/SemanticModel objects.
|
|
78
|
+
|
|
79
|
+
Source recorded as "own_scan" when MF rule is unavailable,
|
|
80
|
+
"mf_equivalent" when the import confirmed availability.
|
|
81
|
+
"""
|
|
82
|
+
# Map entity_name -> set of model names that declare it.
|
|
83
|
+
entity_to_models: dict[str, set[str]] = defaultdict(set)
|
|
84
|
+
|
|
85
|
+
for model in models:
|
|
86
|
+
for e in (model.entities or []):
|
|
87
|
+
name = e.get("name", "")
|
|
88
|
+
if not name:
|
|
89
|
+
continue
|
|
90
|
+
entity_to_models[name].add(model.name)
|
|
91
|
+
|
|
92
|
+
# With only one model in the project there can be no join partners at all —
|
|
93
|
+
# every entity is "orphaned" by definition. Skip the check; it is
|
|
94
|
+
# vacuously true and produces false positives on single-model projects.
|
|
95
|
+
if len(models) < 2:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
issues: list[Issue] = []
|
|
99
|
+
source_note = (
|
|
100
|
+
"mf_equivalent" if _MF_COMMON_ENTITIES_AVAILABLE else "own_scan"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Second pass — emit one Issue per (entity_name, model) pair that is orphaned.
|
|
104
|
+
for model in models:
|
|
105
|
+
for e in (model.entities or []):
|
|
106
|
+
name = e.get("name", "")
|
|
107
|
+
if not name:
|
|
108
|
+
continue
|
|
109
|
+
if len(entity_to_models[name]) == 1:
|
|
110
|
+
issues.append(
|
|
111
|
+
Issue(
|
|
112
|
+
severity="warning",
|
|
113
|
+
dimension="joinability",
|
|
114
|
+
rule="joinability_orphan",
|
|
115
|
+
message=(
|
|
116
|
+
f"Entity '{name}' on model '{model.name}' "
|
|
117
|
+
f"appears on no other model — it cannot "
|
|
118
|
+
f"participate in a join. "
|
|
119
|
+
f"(source: {source_note})"
|
|
120
|
+
),
|
|
121
|
+
location=model.source_file,
|
|
122
|
+
provenance="deterministic",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return issues
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parity_issues(models: list) -> list[Issue]:
|
|
130
|
+
"""
|
|
131
|
+
Flag entity name/stem mismatches that indicate broken join partners.
|
|
132
|
+
|
|
133
|
+
Groups entity names by their normalised stem across all models. When a
|
|
134
|
+
stem maps to multiple distinct names, those names are mismatched join
|
|
135
|
+
candidates — e.g. "customer" (primary) and "customer_id" (foreign)
|
|
136
|
+
both stem to "customer" but differ in name, breaking MetricFlow joins.
|
|
137
|
+
|
|
138
|
+
MetricFlow has no built-in rule for this — entirely our check.
|
|
139
|
+
"""
|
|
140
|
+
# stem -> set of (entity_name, model_name, source_file, entity_type)
|
|
141
|
+
stem_map: dict[str, list[dict]] = defaultdict(list)
|
|
142
|
+
|
|
143
|
+
for model in models:
|
|
144
|
+
for e in (model.entities or []):
|
|
145
|
+
name = e.get("name", "")
|
|
146
|
+
etype = e.get("type", "")
|
|
147
|
+
if not name or etype not in _JOIN_TYPES:
|
|
148
|
+
continue
|
|
149
|
+
s = _stem(name)
|
|
150
|
+
stem_map[s].append(
|
|
151
|
+
{
|
|
152
|
+
"name": name,
|
|
153
|
+
"type": etype,
|
|
154
|
+
"model": model.name,
|
|
155
|
+
"source_file": model.source_file,
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
issues: list[Issue] = []
|
|
160
|
+
|
|
161
|
+
for stem, entries in stem_map.items():
|
|
162
|
+
distinct_names = {e["name"] for e in entries}
|
|
163
|
+
if len(distinct_names) <= 1:
|
|
164
|
+
continue # all consistent — no mismatch
|
|
165
|
+
|
|
166
|
+
# Multiple distinct names for the same stem across models: parity mismatch.
|
|
167
|
+
names_str = ", ".join(sorted(distinct_names))
|
|
168
|
+
models_str = ", ".join(sorted({e["model"] for e in entries}))
|
|
169
|
+
|
|
170
|
+
# Emit one Issue per model that contributes a mismatched name.
|
|
171
|
+
seen_model_name = set()
|
|
172
|
+
for entry in entries:
|
|
173
|
+
if entry["model"] in seen_model_name:
|
|
174
|
+
continue
|
|
175
|
+
seen_model_name.add(entry["model"])
|
|
176
|
+
issues.append(
|
|
177
|
+
Issue(
|
|
178
|
+
severity="warning",
|
|
179
|
+
dimension="joinability",
|
|
180
|
+
rule="joinability_parity",
|
|
181
|
+
message=(
|
|
182
|
+
f"Entity stem '{stem}' has mismatched names "
|
|
183
|
+
f"across models [{models_str}]: [{names_str}]. "
|
|
184
|
+
f"MetricFlow joins require exact name match — "
|
|
185
|
+
f"these will not join correctly."
|
|
186
|
+
),
|
|
187
|
+
location=entry["source_file"],
|
|
188
|
+
provenance="deterministic",
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return issues
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def check_joinability(models: list) -> list[Issue]:
|
|
196
|
+
"""
|
|
197
|
+
Cross-model joinability check. Consumes all NormalizedModels for the project.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
list[Issue] — empty means fully joinable.
|
|
201
|
+
|
|
202
|
+
Checks performed:
|
|
203
|
+
1. Orphan entities (joinability_orphan) — entity on only one model.
|
|
204
|
+
Leverages MetricFlow's CommonEntitysRule algorithm when the import
|
|
205
|
+
is available; falls back to own scan if not.
|
|
206
|
+
2. Name/type parity mismatches (joinability_parity) — same conceptual
|
|
207
|
+
join partner has different entity names across models.
|
|
208
|
+
MetricFlow has no built-in rule for this; we build it ourselves.
|
|
209
|
+
"""
|
|
210
|
+
if not models:
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
issues: list[Issue] = []
|
|
214
|
+
issues.extend(_orphan_issues(models))
|
|
215
|
+
issues.extend(_parity_issues(models))
|
|
216
|
+
return issues
|
trust/judgment.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Judgment combination + guardrail (Task 3.1d).
|
|
3
|
+
|
|
4
|
+
apply_judgment(report: ModelReport, judgment: dict) -> ModelReport
|
|
5
|
+
|
|
6
|
+
Takes the deterministic ModelReport (read-only) produced by scorer.py and an LLM
|
|
7
|
+
judgment payload (per eval/judge.md Step 6) and returns a NEW ModelReport that:
|
|
8
|
+
- adds document_quality per DocumentReport for each key in judgment["documents"]
|
|
9
|
+
- appends provenance="llm_judge" advisory issues to the matching DocumentReport
|
|
10
|
+
|
|
11
|
+
GUARDRAIL — the function enforces a strict read-only contract on the deterministic
|
|
12
|
+
output. Any key in the payload that would touch trust_score, band, gates, context,
|
|
13
|
+
quality, or any provenance="deterministic" issue is silently ignored. Only
|
|
14
|
+
"documents" is read; everything else (e.g. "override_gates") is ignored.
|
|
15
|
+
|
|
16
|
+
Two distinct scores stay distinct:
|
|
17
|
+
- trust_score / band : deterministic (unchanged)
|
|
18
|
+
- document_quality : advisory, per DocumentReport (from judgment)
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import copy
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from trust.report import DocumentReport, Issue, ModelReport
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def apply_judgment(report: ModelReport, judgment: dict[str, Any]) -> ModelReport:
|
|
29
|
+
"""
|
|
30
|
+
Combine a deterministic ModelReport with an LLM judgment payload.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
report:
|
|
35
|
+
The deterministic ModelReport from score_model(). Treated as read-only;
|
|
36
|
+
the caller's object is never mutated.
|
|
37
|
+
judgment:
|
|
38
|
+
Dict produced by the LLM following eval/judge.md Step 6. Only the
|
|
39
|
+
"documents" key is consumed; all other keys are ignored (guardrail).
|
|
40
|
+
|
|
41
|
+
Expected shape::
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
"documents": {
|
|
45
|
+
"<doc_type>": {
|
|
46
|
+
"quality": <int 0-100>,
|
|
47
|
+
"issues": [
|
|
48
|
+
{
|
|
49
|
+
"severity": "warning"|"info"|"critical",
|
|
50
|
+
"dimension": "<string>",
|
|
51
|
+
"rule": "<string>",
|
|
52
|
+
"message": "<string>",
|
|
53
|
+
"location": "<string>"
|
|
54
|
+
},
|
|
55
|
+
...
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
...
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
ModelReport
|
|
65
|
+
A deep copy of the input report with document_quality and llm_judge issues
|
|
66
|
+
attached to the relevant DocumentReports. trust_score, band, gates, context,
|
|
67
|
+
quality, and all deterministic issues are identical to the input.
|
|
68
|
+
"""
|
|
69
|
+
# Deep-copy so the caller's report is never mutated (guardrail: immutability)
|
|
70
|
+
new_report: ModelReport = copy.deepcopy(report)
|
|
71
|
+
|
|
72
|
+
# GUARDRAIL: only read "documents"; ignore everything else in judgment
|
|
73
|
+
doc_judgments: dict[str, Any] = judgment.get("documents", {})
|
|
74
|
+
|
|
75
|
+
for doc_type, payload in doc_judgments.items():
|
|
76
|
+
if doc_type not in new_report.documents:
|
|
77
|
+
# Unknown doc_type from LLM — silently ignore
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
doc: DocumentReport = new_report.documents[doc_type]
|
|
81
|
+
|
|
82
|
+
# Attach advisory document_quality score (separate from deterministic score)
|
|
83
|
+
# Clamp to [0, 100]; skip gracefully if quality is absent or not convertible.
|
|
84
|
+
quality_score = payload.get("quality")
|
|
85
|
+
if quality_score is not None:
|
|
86
|
+
try:
|
|
87
|
+
doc.document_quality = max(0, min(100, int(quality_score)))
|
|
88
|
+
except (ValueError, TypeError):
|
|
89
|
+
pass # non-int/None — leave document_quality unset
|
|
90
|
+
|
|
91
|
+
# Append llm_judge advisory issues (tagged provenance="llm_judge")
|
|
92
|
+
for raw in payload.get("issues", []):
|
|
93
|
+
doc.issues.append(Issue(
|
|
94
|
+
severity=raw.get("severity", "info"),
|
|
95
|
+
dimension=raw.get("dimension", ""),
|
|
96
|
+
rule=raw.get("rule", ""),
|
|
97
|
+
message=raw.get("message", ""),
|
|
98
|
+
location=raw.get("location", ""),
|
|
99
|
+
provenance="llm_judge",
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
return new_report
|