openmapstack 0.2.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.
- openmapstack/__init__.py +6 -0
- openmapstack/__main__.py +3 -0
- openmapstack/checks/__init__.py +112 -0
- openmapstack/checks/geodata.py +318 -0
- openmapstack/checks/overrides.py +263 -0
- openmapstack/checks/presentation.py +168 -0
- openmapstack/checks/project.py +261 -0
- openmapstack/checks/provenance.py +133 -0
- openmapstack/checks/qgis.py +832 -0
- openmapstack/checks/rerun.py +340 -0
- openmapstack/checks/spatial.py +65 -0
- openmapstack/checks/validation.py +288 -0
- openmapstack/checks/visual.py +642 -0
- openmapstack/cli.py +431 -0
- openmapstack/expectations.py +284 -0
- openmapstack/integrity.py +137 -0
- openmapstack/project.py +79 -0
- openmapstack/rerun.py +332 -0
- openmapstack/schema.py +39 -0
- openmapstack/schemas/__init__.py +1 -0
- openmapstack/schemas/project-v1.schema.json +264 -0
- openmapstack/validation.py +1019 -0
- openmapstack/verify.py +386 -0
- openmapstack-0.2.0.dist-info/METADATA +268 -0
- openmapstack-0.2.0.dist-info/RECORD +29 -0
- openmapstack-0.2.0.dist-info/WHEEL +5 -0
- openmapstack-0.2.0.dist-info/entry_points.txt +2 -0
- openmapstack-0.2.0.dist-info/licenses/LICENSE +21 -0
- openmapstack-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Override declaration vs application assertions.
|
|
2
|
+
|
|
3
|
+
See references/project-spec.md section 2.3.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .spatial import connect_spatial
|
|
12
|
+
|
|
13
|
+
from . import (
|
|
14
|
+
AssertionResult,
|
|
15
|
+
failed,
|
|
16
|
+
load_json,
|
|
17
|
+
load_project_yaml,
|
|
18
|
+
not_testable,
|
|
19
|
+
passed,
|
|
20
|
+
project_root,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def declared_count(workspace: Path, count: int, project_dir: str = ".") -> AssertionResult:
|
|
25
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
26
|
+
if proj is None:
|
|
27
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
28
|
+
overrides = proj.get("overrides") or []
|
|
29
|
+
if len(overrides) == count:
|
|
30
|
+
return passed(f"{count} overrides declared")
|
|
31
|
+
return failed(f"expected {count} overrides, found {len(overrides)}", code="override_count_mismatch")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def every_override_has_provenance(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
35
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
36
|
+
if proj is None:
|
|
37
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
38
|
+
overrides = proj.get("overrides") or []
|
|
39
|
+
if not overrides:
|
|
40
|
+
return passed("no overrides declared (vacuously true)")
|
|
41
|
+
|
|
42
|
+
missing: list[str] = []
|
|
43
|
+
for o in overrides:
|
|
44
|
+
oid = o.get("id", "?")
|
|
45
|
+
for field in ("id", "action", "rationale", "created_at", "created_by"):
|
|
46
|
+
if not o.get(field):
|
|
47
|
+
missing.append(f"{oid}.{field}")
|
|
48
|
+
if missing:
|
|
49
|
+
return failed(
|
|
50
|
+
f"overrides missing required provenance fields: {missing}",
|
|
51
|
+
code="override_missing_provenance",
|
|
52
|
+
)
|
|
53
|
+
return passed(f"all {len(overrides)} overrides carry id/action/rationale/author/timestamp")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def evidence_not_placeholder(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
57
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
58
|
+
if proj is None:
|
|
59
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
60
|
+
overrides = proj.get("overrides") or []
|
|
61
|
+
placeholder_markers = {"todo", "tbd", "n/a", "none", "..."}
|
|
62
|
+
bad: list[str] = []
|
|
63
|
+
for o in overrides:
|
|
64
|
+
evidence = o.get("evidence") or []
|
|
65
|
+
for e in evidence:
|
|
66
|
+
value = str(e.get("value", "")).strip().lower()
|
|
67
|
+
if not value or value in placeholder_markers:
|
|
68
|
+
bad.append(o.get("id", "?"))
|
|
69
|
+
if bad:
|
|
70
|
+
return failed(f"overrides with placeholder/empty evidence: {bad}", code="placeholder_evidence")
|
|
71
|
+
return passed("no placeholder evidence found")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def application_status(
|
|
75
|
+
workspace: Path, id: str, status: str, project_dir: str = ".",
|
|
76
|
+
report_path: str = "validation/latest-report.json",
|
|
77
|
+
) -> AssertionResult:
|
|
78
|
+
"""The run report must record the given override id as applied/rejected/not_testable."""
|
|
79
|
+
report = load_json(project_root(workspace, project_dir) / report_path)
|
|
80
|
+
if report is None:
|
|
81
|
+
return not_testable(f"no validation report found at {report_path}", code="report_missing")
|
|
82
|
+
|
|
83
|
+
checks = report.get("checks", [])
|
|
84
|
+
override_check = next((c for c in checks if c.get("id") == "overrides_applied"), None)
|
|
85
|
+
results = (override_check or {}).get("results") or report.get("overrides") or []
|
|
86
|
+
entry = next((r for r in results if r.get("id") == id), None)
|
|
87
|
+
if entry is None:
|
|
88
|
+
return failed(f"override {id} has no application result in the report", code="override_result_missing")
|
|
89
|
+
actual = entry.get("status")
|
|
90
|
+
if actual == status:
|
|
91
|
+
return passed(f"override {id} status == {status}")
|
|
92
|
+
return failed(
|
|
93
|
+
f"override {id} status mismatch: expected {status!r}, got {actual!r}",
|
|
94
|
+
code="override_status_mismatch",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def from_value_matches_source(
|
|
99
|
+
workspace: Path, id: str, source_path: str, id_field: str, project_dir: str = ".",
|
|
100
|
+
) -> AssertionResult:
|
|
101
|
+
"""A modify_attribute override's asserted `from` must actually match the
|
|
102
|
+
immutable source file's current value for the targeted feature/field —
|
|
103
|
+
not merely be declared. `source_path` is the on-disk source file the
|
|
104
|
+
override targets (e.g. data/source/pois.geojson)."""
|
|
105
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
106
|
+
if proj is None:
|
|
107
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
108
|
+
overrides = proj.get("overrides") or []
|
|
109
|
+
entry = next((o for o in overrides if o.get("id") == id), None)
|
|
110
|
+
if entry is None:
|
|
111
|
+
return failed(f"override {id} not declared", code="override_not_declared")
|
|
112
|
+
if entry.get("action") != "modify_attribute":
|
|
113
|
+
return passed(f"override {id} is not modify_attribute; from-value check not applicable")
|
|
114
|
+
change = entry.get("change") or {}
|
|
115
|
+
if "from" not in change:
|
|
116
|
+
return failed(
|
|
117
|
+
f"override {id} is modify_attribute but declares no change.from",
|
|
118
|
+
code="from_value_undeclared",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
from . import project_root as _project_root
|
|
122
|
+
|
|
123
|
+
target = entry.get("target") or {}
|
|
124
|
+
feature_id = target.get("feature_id")
|
|
125
|
+
field = change.get("field")
|
|
126
|
+
src_file = _project_root(workspace, project_dir) / source_path
|
|
127
|
+
if not src_file.exists():
|
|
128
|
+
return not_testable(f"source file {source_path} not found, cannot verify from-value", code="source_missing")
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
con = connect_spatial()
|
|
132
|
+
if con is None:
|
|
133
|
+
return not_testable(
|
|
134
|
+
"duckdb spatial not available in this environment",
|
|
135
|
+
code="duckdb_unavailable",
|
|
136
|
+
)
|
|
137
|
+
row = con.execute(
|
|
138
|
+
f'SELECT "{field}" FROM ST_Read(\'{src_file.as_posix()}\') WHERE "{id_field}" = ?',
|
|
139
|
+
[feature_id],
|
|
140
|
+
).fetchone()
|
|
141
|
+
except Exception as exc: # noqa: BLE001
|
|
142
|
+
return not_testable(f"could not read source {source_path}: {exc}", code="read_error")
|
|
143
|
+
|
|
144
|
+
if row is None:
|
|
145
|
+
return failed(
|
|
146
|
+
f"override {id} target feature {feature_id!r} not found in source {source_path}",
|
|
147
|
+
code="target_feature_missing",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
actual_source_value = row[0]
|
|
151
|
+
declared_from = change.get("from")
|
|
152
|
+
if str(actual_source_value) != str(declared_from):
|
|
153
|
+
return failed(
|
|
154
|
+
f"override {id} asserts from={declared_from!r} but immutable source has "
|
|
155
|
+
f"{field}={actual_source_value!r} for {feature_id!r} — must reject/fail, not apply",
|
|
156
|
+
code="from_value_mismatch",
|
|
157
|
+
)
|
|
158
|
+
return passed(f"override {id} from={declared_from!r} matches immutable source value")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def source_files_byte_identical(
|
|
162
|
+
workspace: Path,
|
|
163
|
+
paths: list[str],
|
|
164
|
+
hashes_before: dict[str, str] | Any = None,
|
|
165
|
+
rerun_workspace: str | None = None,
|
|
166
|
+
require_complete_tree: bool = False,
|
|
167
|
+
project_dir: str = ".",
|
|
168
|
+
) -> AssertionResult:
|
|
169
|
+
"""Verify declared immutable source files never changed, using real bytes only.
|
|
170
|
+
|
|
171
|
+
Two independent baselines are supported, and both are recomputed from
|
|
172
|
+
actual files rather than trusted from any declared/authored value:
|
|
173
|
+
|
|
174
|
+
- ``hashes_before`` — a runner-captured pre-execution snapshot, normally
|
|
175
|
+
supplied via the ``$SOURCE_HASHES`` magic value. A missing/empty/
|
|
176
|
+
non-mapping baseline, or one missing an entry for a requested path,
|
|
177
|
+
is reported ``not_testable`` rather than silently treated as a pass.
|
|
178
|
+
- ``rerun_workspace`` — a second real workspace (normally the ``$RERUN``
|
|
179
|
+
clean-rerun copy) whose ``paths`` are hashed and compared directly
|
|
180
|
+
against this workspace's files.
|
|
181
|
+
|
|
182
|
+
Exactly one of the two must be usable for a given call.
|
|
183
|
+
"""
|
|
184
|
+
import hashlib
|
|
185
|
+
|
|
186
|
+
if not paths:
|
|
187
|
+
return not_testable("no paths declared to verify byte-identity", code="no_paths_declared")
|
|
188
|
+
|
|
189
|
+
root = project_root(workspace, project_dir)
|
|
190
|
+
|
|
191
|
+
def _hash(target: Path) -> str:
|
|
192
|
+
return "sha256:" + hashlib.sha256(target.read_bytes()).hexdigest()
|
|
193
|
+
|
|
194
|
+
if rerun_workspace is not None:
|
|
195
|
+
rerun_root = Path(rerun_workspace)
|
|
196
|
+
if not rerun_root.exists():
|
|
197
|
+
return not_testable(f"rerun workspace {rerun_workspace} does not exist", code="rerun_workspace_missing")
|
|
198
|
+
missing: list[str] = []
|
|
199
|
+
mismatches: list[str] = []
|
|
200
|
+
for rel in paths:
|
|
201
|
+
original = root / rel
|
|
202
|
+
rerun = rerun_root / rel
|
|
203
|
+
if not original.is_file() or not rerun.is_file():
|
|
204
|
+
missing.append(rel)
|
|
205
|
+
continue
|
|
206
|
+
if _hash(original) != _hash(rerun):
|
|
207
|
+
mismatches.append(rel)
|
|
208
|
+
if missing:
|
|
209
|
+
return not_testable(f"source files missing in one of the two workspaces: {missing}", code="file_missing")
|
|
210
|
+
if mismatches:
|
|
211
|
+
return failed(f"source files mutated across rerun: {mismatches}", code="source_mutated")
|
|
212
|
+
return passed(f"all {len(paths)} source files byte-identical between workspace and rerun")
|
|
213
|
+
|
|
214
|
+
if not isinstance(hashes_before, dict) or not hashes_before:
|
|
215
|
+
return not_testable(
|
|
216
|
+
"no pre-execution hash baseline is available for comparison", code="baseline_missing"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if require_complete_tree:
|
|
220
|
+
actual_paths = {
|
|
221
|
+
path.relative_to(root).as_posix()
|
|
222
|
+
for relative_dir in ("data/source", "data/overrides")
|
|
223
|
+
for path in (root / relative_dir).rglob("*")
|
|
224
|
+
if path.is_file()
|
|
225
|
+
}
|
|
226
|
+
baseline_paths = {Path(path).as_posix() for path in hashes_before}
|
|
227
|
+
unbaselined_tree = sorted(actual_paths - baseline_paths)
|
|
228
|
+
stale_baseline = sorted(baseline_paths - actual_paths)
|
|
229
|
+
if unbaselined_tree or stale_baseline:
|
|
230
|
+
return failed(
|
|
231
|
+
f"immutable source baseline does not exactly cover source/override trees; "
|
|
232
|
+
f"unbaselined={unbaselined_tree}, missing={stale_baseline}",
|
|
233
|
+
code="source_baseline_incomplete",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
checked_paths = sorted(hashes_before) if require_complete_tree else paths
|
|
237
|
+
mismatches = []
|
|
238
|
+
missing = []
|
|
239
|
+
unbaselined: list[str] = []
|
|
240
|
+
for rel in checked_paths:
|
|
241
|
+
expected = hashes_before.get(rel)
|
|
242
|
+
if not expected:
|
|
243
|
+
unbaselined.append(rel)
|
|
244
|
+
continue
|
|
245
|
+
target = root / rel
|
|
246
|
+
if not target.exists():
|
|
247
|
+
missing.append(rel)
|
|
248
|
+
continue
|
|
249
|
+
digest = _hash(target)
|
|
250
|
+
normalized_expected = expected if str(expected).startswith("sha256:") else f"sha256:{expected}"
|
|
251
|
+
if digest != normalized_expected:
|
|
252
|
+
mismatches.append(rel)
|
|
253
|
+
if unbaselined:
|
|
254
|
+
return not_testable(f"no pre-execution hash baseline for: {unbaselined}", code="baseline_incomplete")
|
|
255
|
+
if missing:
|
|
256
|
+
return not_testable(f"source files missing, cannot compare: {missing}", code="file_missing")
|
|
257
|
+
if mismatches:
|
|
258
|
+
return failed(
|
|
259
|
+
f"source files mutated since pre-execution baseline: {mismatches}", code="source_mutated"
|
|
260
|
+
)
|
|
261
|
+
return passed(
|
|
262
|
+
f"all {len(checked_paths)} source files byte-identical to pre-execution baseline"
|
|
263
|
+
)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Presentation-contract assertions: semantic roles, layer groups, controls parity.
|
|
2
|
+
|
|
3
|
+
See references/project-spec.md sections 2.7 and 3.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import AssertionResult, failed, get_in, load_project_yaml, passed, warning
|
|
11
|
+
|
|
12
|
+
SEMANTIC_ROLES = {
|
|
13
|
+
"primary_result", "secondary_result", "source", "context", "constraint",
|
|
14
|
+
"excluded_area", "warning", "user_override", "planned", "hypothetical",
|
|
15
|
+
"selected_feature",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def layers_use_semantic_roles(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
20
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
21
|
+
if proj is None:
|
|
22
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
23
|
+
layers = get_in(proj, "presentation.map.layers", []) or []
|
|
24
|
+
if not layers:
|
|
25
|
+
return warning("no layers declared under presentation.map.layers", code="no_layers_declared")
|
|
26
|
+
missing = [layer.get("source", "?") for layer in layers if not layer.get("semantic_role")]
|
|
27
|
+
if missing:
|
|
28
|
+
return failed(f"layers missing semantic_role: {missing}", code="semantic_role_missing")
|
|
29
|
+
return passed(f"all {len(layers)} layers declare a semantic_role")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def required_layer_groups_exist(workspace: Path, groups: list[str], project_dir: str = ".") -> AssertionResult:
|
|
33
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
34
|
+
if proj is None:
|
|
35
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
36
|
+
declared = {g.get("id") for g in get_in(proj, "presentation.map.layer_groups", []) or []}
|
|
37
|
+
missing = [g for g in groups if g not in declared]
|
|
38
|
+
if missing:
|
|
39
|
+
return failed(f"missing required layer groups: {missing}", code="layer_group_missing")
|
|
40
|
+
return passed(f"all required layer groups present: {groups}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def controls_match_pipeline(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
44
|
+
"""presentation.controls.filters[].canonical must equal the threshold the
|
|
45
|
+
pipeline actually used for the equivalent processing.steps parameter, and
|
|
46
|
+
presentation.controls.scenarios[].override must reference a real
|
|
47
|
+
override id. A drift here means the view can misrepresent the run."""
|
|
48
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
49
|
+
if proj is None:
|
|
50
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
51
|
+
|
|
52
|
+
override_ids = {o.get("id") for o in (proj.get("overrides") or [])}
|
|
53
|
+
scenarios = get_in(proj, "presentation.controls.scenarios", []) or []
|
|
54
|
+
errors: list[str] = []
|
|
55
|
+
for s in scenarios:
|
|
56
|
+
if s.get("override") not in override_ids:
|
|
57
|
+
errors.append(f"scenario {s.get('id')} references unknown override {s.get('override')!r}")
|
|
58
|
+
|
|
59
|
+
steps = get_in(proj, "processing.steps", []) or []
|
|
60
|
+
filters = get_in(proj, "presentation.controls.filters", []) or []
|
|
61
|
+
for f in filters:
|
|
62
|
+
field = f.get("field")
|
|
63
|
+
canonical = f.get("canonical")
|
|
64
|
+
if field is None or canonical is None:
|
|
65
|
+
continue
|
|
66
|
+
# Look for a step expression mentioning this field and the canonical value.
|
|
67
|
+
matching_steps = [
|
|
68
|
+
s for s in steps
|
|
69
|
+
if field in str(s.get("expression", "")) or field == s.get("output_field")
|
|
70
|
+
]
|
|
71
|
+
if not matching_steps:
|
|
72
|
+
continue # not every control necessarily maps 1:1 to a single step; skip silently
|
|
73
|
+
# A multi_select control's canonical position is a list of values, and
|
|
74
|
+
# each must appear in the rule the pipeline ran. Stringifying the whole
|
|
75
|
+
# list and substring-searching the SQL can never match.
|
|
76
|
+
expressions = [str(s.get("expression", "")) for s in matching_steps]
|
|
77
|
+
wanted = canonical if isinstance(canonical, list) else [canonical]
|
|
78
|
+
absent = [v for v in wanted if not any(str(v) in e for e in expressions)]
|
|
79
|
+
if absent:
|
|
80
|
+
errors.append(
|
|
81
|
+
f"control {f.get('id')} canonical value(s) {absent!r} not found in matching step expression(s)"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if errors:
|
|
85
|
+
return failed("; ".join(errors), errors=errors, code="control_pipeline_drift")
|
|
86
|
+
return passed(f"{len(filters)} filter control(s) and {len(scenarios)} scenario control(s) consistent")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def edit_targets_reference_real_sources(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
90
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
91
|
+
if proj is None:
|
|
92
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
93
|
+
sources = set((proj.get("sources") or {}).keys())
|
|
94
|
+
targets = get_in(proj, "presentation.editing.targets", {}) or {}
|
|
95
|
+
if not targets:
|
|
96
|
+
return passed("no edit targets declared (vacuously true)")
|
|
97
|
+
bad = [k for k, t in targets.items() if t.get("source") not in sources]
|
|
98
|
+
if bad:
|
|
99
|
+
return failed(f"edit targets referencing unknown sources: {bad}", code="edit_target_unknown_source")
|
|
100
|
+
return passed(f"all {len(targets)} edit targets reference real project sources")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def consistent_with(
|
|
104
|
+
workspace: Path, other_project_dir: str, project_dir: str = ".",
|
|
105
|
+
required_shared_keys: list[str] | None = None,
|
|
106
|
+
) -> AssertionResult:
|
|
107
|
+
"""Two different analyses over the same skill must share stable UX
|
|
108
|
+
semantics (layout type, sidebar organization, semantic-role vocabulary,
|
|
109
|
+
provenance_ui shape, editing capability keys) even though their actual
|
|
110
|
+
layers/results differ. Do not require byte-identical output."""
|
|
111
|
+
proj_a = load_project_yaml(workspace, project_dir)
|
|
112
|
+
proj_b = load_project_yaml(workspace, other_project_dir)
|
|
113
|
+
if proj_a is None or proj_b is None:
|
|
114
|
+
return failed("one of the two projects is missing project.yaml", code="manifest_missing")
|
|
115
|
+
|
|
116
|
+
errors: list[str] = []
|
|
117
|
+
|
|
118
|
+
layout_a = get_in(proj_a, "presentation.layout.type")
|
|
119
|
+
layout_b = get_in(proj_b, "presentation.layout.type")
|
|
120
|
+
if layout_a != layout_b:
|
|
121
|
+
errors.append(f"layout.type differs: {layout_a!r} vs {layout_b!r}")
|
|
122
|
+
|
|
123
|
+
org_a = get_in(proj_a, "presentation.layout.sidebar.organization")
|
|
124
|
+
org_b = get_in(proj_b, "presentation.layout.sidebar.organization")
|
|
125
|
+
if org_a != org_b:
|
|
126
|
+
errors.append(f"sidebar.organization differs: {org_a!r} vs {org_b!r}")
|
|
127
|
+
|
|
128
|
+
prov_a = set(get_in(proj_a, "presentation.provenance_ui", {}) or {})
|
|
129
|
+
prov_b = set(get_in(proj_b, "presentation.provenance_ui", {}) or {})
|
|
130
|
+
if prov_a != prov_b:
|
|
131
|
+
errors.append(f"provenance_ui keys differ: {sorted(prov_a)} vs {sorted(prov_b)}")
|
|
132
|
+
|
|
133
|
+
edit_a = set(get_in(proj_a, "presentation.editing", {}) or {})
|
|
134
|
+
edit_b = set(get_in(proj_b, "presentation.editing", {}) or {})
|
|
135
|
+
if edit_a != edit_b:
|
|
136
|
+
errors.append(f"editing capability keys differ: {sorted(edit_a)} vs {sorted(edit_b)}")
|
|
137
|
+
|
|
138
|
+
roles_a = {l.get("semantic_role") for l in get_in(proj_a, "presentation.map.layers", []) or []}
|
|
139
|
+
roles_b = {l.get("semantic_role") for l in get_in(proj_b, "presentation.map.layers", []) or []}
|
|
140
|
+
if not (roles_a & roles_b):
|
|
141
|
+
errors.append(f"no shared semantic roles between projects: {roles_a} vs {roles_b}")
|
|
142
|
+
|
|
143
|
+
for key in required_shared_keys or []:
|
|
144
|
+
va = get_in(proj_a, key)
|
|
145
|
+
vb = get_in(proj_b, key)
|
|
146
|
+
if va != vb:
|
|
147
|
+
errors.append(f"{key} differs: {va!r} vs {vb!r}")
|
|
148
|
+
|
|
149
|
+
if errors:
|
|
150
|
+
return failed("; ".join(errors), errors=errors, code="ux_semantics_drift")
|
|
151
|
+
return passed("presentation semantics stable across both analyses")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def distinguishable_layer_semantics(workspace: Path, project_dir: str = ".") -> AssertionResult:
|
|
155
|
+
"""Source, result, override, and hypothetical data must remain visually
|
|
156
|
+
distinguishable — i.e. use different semantic_role values, not all the
|
|
157
|
+
same role."""
|
|
158
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
159
|
+
if proj is None:
|
|
160
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
161
|
+
layers = get_in(proj, "presentation.map.layers", []) or []
|
|
162
|
+
roles = {layer.get("semantic_role") for layer in layers if layer.get("semantic_role")}
|
|
163
|
+
if len(layers) > 1 and len(roles) <= 1:
|
|
164
|
+
return failed(
|
|
165
|
+
"all layers share a single semantic_role; source/result/override/hypothetical indistinguishable",
|
|
166
|
+
code="indistinguishable_layers",
|
|
167
|
+
)
|
|
168
|
+
return passed(f"layers use {len(roles)} distinct semantic role(s)")
|