Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/evals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,17 @@ jobs:
python -m coverage json
python -m coverage xml

- name: Smoke-test CLI against worked example
- name: Smoke-test CLI and committed worked-example evidence
# --preflight, not by preference: the example's data/source and
# data/derived are gitignored (a county cadastral snapshot alone is
# 55 MB), so a fresh checkout has no outputs for the full artifact
# checks to read. What preflight does still see is the committed
# inputs, and `runs.present_files` fails here if pipeline.py has been
# edited without re-running -- the drift that shipped in #14. The
# complete artifact validation runs in example.yml, which regenerates
# the data first.
run: |
python -m unittest -v tests.test_verify.CommittedWorkedExampleContractTests
openmapstack validate examples/tartu-development/project.yaml --preflight
openmapstack inspect examples/tartu-development/project.yaml --json > /tmp/openmapstack-inspection.json

Expand Down
83 changes: 83 additions & 0 deletions .github/workflows/example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Worked example (full validation)

# The fixture job can only preflight the worked example: its sources and
# derived outputs are gitignored, so a fresh checkout has nothing for the
# artifact checks to read. This job regenerates them from the real Estonian
# services and then runs the validation the fixture job cannot -- including
# `openmapstack verify`, which is the only thing that compares project.qgz
# against what the manifest claims.
#
# It reaches three external services (Maa- ja Ruumiamet S3, the ETAK WFS, and
# Tartu's ArcGIS Feature Services), so it is deliberately not on every PR:
# an outage upstream must not redden unrelated work.

on:
schedule:
- cron: "17 5 * * 1"
workflow_dispatch:
pull_request:
paths:
- "examples/tartu-development/**"
- "openmapstack/**"
- ".github/workflows/example.yml"

jobs:
worked-example:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install the CLI with geodata support
run: |
pip install ".[geo]"
pip install pyproj

- name: Regenerate the example from its real sources
working-directory: examples/tartu-development
run: python pipeline.py

- name: Validate every artifact, not just the manifest
run: openmapstack validate examples/tartu-development/project.yaml

- name: Verify the delivered product against the manifest
run: openmapstack verify examples/tartu-development

- name: Prove every declared QGIS layer paints in the right place
run: |
docker run --rm \
-e QT_QPA_PLATFORM=offscreen \
-v "${PWD}:/workspace" \
-w /workspace \
qgis/qgis:3.44-trixie \
python3 -m unittest -v \
tests.evals.test_qgis_assertions.RuntimeLoadWithRealPyqgisTests.test_real_worked_example_every_declared_layer_renders

- name: The committed QGIS project must be the one the pipeline writes
# project.qgz is generated deterministically and is committed, so a
# regenerated copy that differs means the repository ships a QGIS
# project its own pipeline no longer produces. Its legend carries
# facility counts, so an upstream change to Tartu's education data
# will trip this too -- also correctly: the committed example has then
# stopped describing the current sources and wants regenerating.
run: |
git diff --exit-code -- examples/tartu-development/project.qgz || {
echo "::error::The committed project.qgz differs from the one pipeline.py"\
"just produced. Re-run examples/tartu-development/pipeline.py and"\
"commit the regenerated project.qgz (and its run record)."
git diff --stat -- examples/tartu-development/project.qgz
exit 1
}

- name: Upload the regenerated run record
if: always()
uses: actions/upload-artifact@v4
with:
name: worked-example-run
path: |
examples/tartu-development/runs/
examples/tartu-development/validation/latest-report.json
4 changes: 2 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Hard rules for every material analysis — each is expanded in `references/proje
* **The manifest must resolve.** Every step input is a source key or an earlier step's output, spelled as the producer declared it; every `generated_by` names a real step (`manifest_graph_resolves`).
* **One canonical implementation creates every declared output.** Convenience/E2E entrypoints may wrap `pipeline.py` but must not duplicate its processing, QGIS, or report logic.
* **Build a layer- and style-perfect QGIS project (`project.qgz`)** mirroring the web view: matching layer-tree groups, identical categorized styles, `./path.gpkg|layername=name` datasources, and a regional tiled basemap. **Success means valid layers, not exit code 0** — pin the runtime, and when PyQGIS is available require every layer `isValid()`; otherwise record `not_testable`, never an implicit pass. Two traps make a project that passes every one of those checks still show the wrong map, so check them explicitly:
* **Every layer declares its own `<srs>`, basemaps included.** A layer without one is assumed to be in the project CRS and never reprojected — a Web Mercator basemap in an EPSG:3301 project then renders ~1500 km from the data, under correctly placed analysis layers. Prefer building layers through the PyQGIS API, which resolves the provider's CRS for you; the trap is specific to hand-written `.qgs` XML.
* **Every layer declares a complete `<srs>`, basemaps included, and project reprojection is enabled.** A layer without one is assumed to be in the project CRS and never reprojected. An auth-id-only `<spatialrefsys>` is also broken: QGIS can still report `EPSG:3301` while treating the CRS as invalid and silently painting nothing. Emit WKT or PROJ alongside the identifiers and set `SpatialRefSys/ProjectionsEnabled` to `1`. Prefer building layers through the PyQGIS API, which serializes these fields for you; the trap is specific to hand-written `.qgs` XML.
* **A QGIS layer tree stacks the opposite way to a web map.** `presentation.map.layers` is ordered bottom-to-top, while a layer tree paints its *first* entry on top, so write the tree in reverse manifest order with the basemap last. Copying the manifest order verbatim puts opaque analysis fills over the point layers that belong above them, and the points vanish.
* **Separate analysis semantics from rendering.** Declare semantic presentation roles; don't reinvent layout/colors/UX per run.
* **Ship a reconfigurable view, and never let it misrepresent the run.** Organise the sidebar into tabs of collapsible sections, give every layer group an on/off control, and expose the analysis parameters and scenario overrides as live controls. Each control opens at the value declared in `presentation.controls` and returning there must reproduce the published numbers; any other position labels itself exploratory and offers a reset. The browser re-applies published rules to values the pipeline measured — it never measures geometry, and a control that changes a shape switches between buffers the pipeline materialised.
Expand Down Expand Up @@ -116,7 +116,7 @@ For simple one-shot questions (single CRS conversion, one `ogr2ogr` invocation),

* Hallucinating or fabricating mock coordinates and geometries instead of retrieving real source data (unless the user gave explicit, informed consent for a synthetic mock test)
* Generating a QGIS project that lacks the web dashboard's layers, omits basemaps, or uses broken OGR datasource syntax (`path.gpkg|layer` without `layername=`), causing layers to load as non-spatial attribute tables
* Writing `.qgs` XML by hand with no `<srs>` on a tile basemap, or copying the manifest's layer order straight into the layer tree — both produce a project where every layer is valid and every datasource resolves, yet the map shows the wrong place or silently hides a layer
* Writing `.qgs` XML by hand with no `<srs>`, an auth-id-only CRS block, or no `ProjectionsEnabled`, or copying the manifest's layer order straight into the layer tree — these produce a project where every layer is valid and every datasource resolves, yet the map shows the wrong place or silently hides a layer
* Producing Shapefile as new output (column truncation, 2GB limit, no UTF-8, multi-file)
* Calling `.distance()`, `.buffer()`, or `.area` on geographic CRS (EPSG:4326) — degrees are not meters; unless specific tool explicitly supports wgs84 based geodesic calculations
* Web Mercator (EPSG:3857) for area or distance calculations — it is not equal-area, and the units are not in meters except at the equator
Expand Down
6 changes: 2 additions & 4 deletions docs/maintainers/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ Before changing a checker because one layer is green and another red, confirm wh

`openmapstack validate ... --preflight` deliberately skips checks that need produced artifacts, validation reports, and run records. It is useful before a project has run; it is not full health evidence.

**Current temporary debt (2026-09-02):** issue #14 tracks that `examples/tartu-development` fails its own full verification because of a stale run-record inventory and manifest/QGIS layer-group drift, while ordinary CI currently smoke-tests it with `--preflight`. Remove/update this note when #14 is resolved and protected by the appropriate CI path.

Useful comparison:

```bash
Expand Down Expand Up @@ -51,7 +49,7 @@ Do not reintroduce hardcoded `geom` assumptions in new generic checks. A checker

Two failures have already escaped simpler validity checks:

1. **Missing layer CRS in hand-written `.qgs` XML.** A Web-Mercator basemap with no `<srs>` may be interpreted in the project CRS and render ~1500 km from the analysis while every datasource still looks valid. Prefer the PyQGIS API where possible; it resolves provider CRS. Static checks require declared CRS for all layers.
1. **Missing or incomplete CRS in hand-written `.qgs` XML.** A Web-Mercator basemap with no `<srs>` may be interpreted in the project CRS and render ~1500 km from the analysis. An auth-id-only `<spatialrefsys>` is just as dangerous: `authid()` looks correct while QGIS treats the CRS as invalid and silently cannot transform or paint the layer. Prefer the PyQGIS API where possible. Static checks require full WKT/PROJ definitions and `ProjectionsEnabled`; real-QGIS checks require each manifest layer to change the rendered pixels.
2. **Layer-tree ordering.** MapLibre/web layers are conventionally declared bottom-to-top, while QGIS paints the first tree entry on top. Copying manifest order verbatim can bury point/line layers under opaque polygons. The generated QGIS tree needs the appropriate reverse paint order.

A nonblank render alone is insufficient. The visual suite includes layer-removal comparison and manifest reconciliation because a rendered image can be nonblank while a declared layer is absent.
Expand Down Expand Up @@ -119,4 +117,4 @@ When a plausible wrong project from a live/user run survives the current checks,

Retained live/visual evidence belongs under `evals/results/<run-id>/...` and CI artifacts. Do not treat generated result JSON, screenshots, event streams, or temporary projects as canonical repository state unless a fixture intentionally owns them.

The normalized `agent.json` is vendor-neutral audit data; raw provider events are diagnostics. Assistant final-answer prose is never the correctness oracle.
The normalized `agent.json` is vendor-neutral audit data; raw provider events are diagnostics. Assistant final-answer prose is never the correctness oracle.
3 changes: 2 additions & 1 deletion evals/COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Legend: ✅ covered · ⚠️ partially covered · ❌ not covered (tracked belo
| CRS/axis-order or output-metadata mismatch | 007 (real 3301 coordinates vs declared CRS cross-checked) | 914 `crs-metadata-mismatch` (relabelled output CRS) |
| Wrong analysis CRS | 001 (analysis_crs enforced) | 902 `wrong-crs` |
| Geographic CRS used for metric operations | every case (`geodata.crs_not_used_for_metrics`) | — |
| Basemap CRS declared (QGIS render lands on the data) | every visual-leg case | `qgis.every_layer_declares_crs` static gate |
| Complete QGIS layer CRS + project reprojection enabled | every visual-leg case | 922 `qgis-incomplete-crs` |

## Source

Expand Down Expand Up @@ -78,6 +78,7 @@ Legend: ✅ covered · ⚠️ partially covered · ❌ not covered (tracked belo
| Risk | Positive | Mutation |
|---|---|---|
| QGIS runtime load + non-blank render | 001, 006 (visual legs) | 910 `qgis-false-success` |
| Every declared QGIS layer changes rendered pixels | 001, 006 (visual legs) + worked-example CI | 922 `qgis-incomplete-crs` (static twin) |
| Manifest↔QGIS layer/CRS reconciliation | 001, 006 | — |
| Interactive basemap (tiles + attribution) | 001, 006 (MapLibre + OSM XYZ) | 913 `basemap-missing` |
| Manifest claims visible in the product | 001, 006 | 912 `dashboard-silent-warnings` |
Expand Down
8 changes: 4 additions & 4 deletions evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ denominators separate:
- `integration_visual`: rendered QGIS/browser integration checks.

Cases 001–006 support both fixture and live execution (001 and 006 also run a
visual leg). Cases 901–911 are fixture-only mutations; cases 912 and 913 are
visual-only mutations, proving that a dashboard which hides a manifest warning
and one that ships no background map each fail in a real browser. Mutation
detection is never included in contract or agent pass rates.
visual leg). Cases 901–911 and 914–922 are fixture-only mutations; cases 912
and 913 are visual-only mutations, proving that a dashboard which hides a
manifest warning and one that ships no background map each fail in a real
browser. Mutation detection is never included in contract or agent pass rates.

Every score type also publishes a `capability` block — assertions evaluated,
how many were `not_testable`, and how many soft gates went unmet — and the
Expand Down
24 changes: 24 additions & 0 deletions evals/cases/922-qgis-incomplete-crs/expected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
id: 922-qgis-incomplete-crs
case_type: mutation
modes: [fixture]
score_types: { fixture: mutation_tests }
project_dir: project
hard_gate: true
mutation:
control_generator: "{python} {evals_dir}/fixtures/reference_pipeline/gen.py {project_dir}"
fixture:
generator: "{python} {evals_dir}/fixtures/reference_pipeline/gen.py {project_dir} --break=qgis_incomplete_crs"
source_baseline:
- { source: ../../fixtures/mini-tartu/parcels.geojson, destination: data/source/parcels.geojson }
- { source: ../../fixtures/mini-tartu/roads.geojson, destination: data/source/roads.geojson }
- { source: ../../fixtures/mini-tartu/pois.geojson, destination: data/source/pois.geojson }

assertions:
- assert: project.conforms_to_schema
- assert: validation.run_record_matches
- assert: qgis.static_valid
# An authority id is not a CRS definition. QGIS still reports authid(),
# yet cannot transform the layer and silently paints nothing.
- assert: qgis.every_layer_declares_crs
expect: failed
expect_code: layer_crs_incomplete
Loading
Loading