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.
@@ -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)")