feat(ui,api): edit model predictions and save versioned results - #136
feat(ui,api): edit model predictions and save versioned results#136Caleb Robinson (calebrob6) wants to merge 10 commits into
Conversation
RC artifacts readyAll branch deployment references use the same RC tag:
|
RC artifacts readyAll branch deployment references use the same RC tag:
|
There was a problem hiding this comment.
Pull request overview
Adds a full prediction-editing workflow for trained and embedding models, backed by queued artifact preparation and immutable versioned GeoPackages.
Changes:
- Adds Prediction Editor UI, classification controls, swipe comparison, polling, and version history.
- Adds API, queue, processor, and geospatial workflows for preparation and saving.
- Adds specifications and comprehensive unit coverage.
Reviewed changes
Copilot reviewed 45 out of 47 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
api/hastefuncapi/function_app.py |
Adds editing and artifact APIs. |
api/hastefuncqueues/function_app.py |
Adds preparation queue worker. |
docker/data-init/upload_data.py |
Initializes new artifact metadata. |
docker/docker-compose.yml |
Configures preparation queue. |
docs/api/hastefuncapi.md |
Documents new endpoints. |
hastelib/logs/embedding_friendly.log |
Updates embedding log fixture. |
hastelib/logs/prediction_tiles_friendly.log |
Adds preparation log fixture. |
hastelib/pyproject.toml |
Registers workflow tooling. |
hastelib/src/hastegeo/core/config.py |
Defines queue and artifact settings. |
hastelib/src/hastegeo/core/models/predictions.py |
Defines request validation models. |
hastelib/src/hastegeo/core/models/projects.py |
Adds prediction-version metadata. |
hastelib/src/hastegeo/core/processors/imagery.py |
Schedules footprint tiling. |
hastelib/src/hastegeo/core/processors/prediction_edits.py |
Applies and versions edits. |
hastelib/src/hastegeo/core/processors/prediction_tiles.py |
Orchestrates artifact preparation. |
hastelib/src/hastegeo/core/utils/predictions.py |
Normalizes prediction schemas. |
hastelib/src/hastegeo/workflows/prepare_prediction_tiles.py |
Builds tiles and sidecars. |
hastelib/tests/core/models/__init__.py |
Initializes model tests. |
hastelib/tests/core/models/test_prediction_wire_models.py |
Tests request validation. |
hastelib/tests/core/processors/test_imagery_footprint_tiles.py |
Tests eager tiling scheduling. |
hastelib/tests/core/processors/test_prediction_edits.py |
Tests edit persistence. |
hastelib/tests/core/processors/test_prediction_tiles.py |
Tests preparation orchestration. |
hastelib/tests/core/processors/test_prediction_tiles_layer.py |
Tests layer-only preparation. |
hastelib/tests/core/processors/test_prediction_tiles_request.py |
Tests preparation requests. |
hastelib/tests/core/utils/test_predictions.py |
Tests schema normalization. |
hastelib/tests/workflows/test_prepare_prediction_tiles.py |
Tests geospatial preparation. |
local.settings.example.jsonc |
Documents local queue setting. |
spec/architecture/decisions/0005-versioned-derived-prediction-artifacts.md |
Records versioning architecture. |
spec/features/prediction-editing/README.md |
Summarizes the feature. |
spec/features/prediction-editing/data-model.md |
Documents metadata changes. |
spec/features/prediction-editing/design.md |
Defines technical design. |
spec/features/prediction-editing/impact-analysis.md |
Assesses risks and impact. |
spec/features/prediction-editing/plan.md |
Tracks implementation work. |
spec/features/prediction-editing/rollout.md |
Defines rollout strategy. |
spec/features/prediction-editing/test-plan.md |
Defines validation coverage. |
spec/features/prediction-editing/user-stories.md |
Defines user requirements. |
ui/src/Components/AppBody.jsx |
Registers editor route. |
ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx |
Shares PMTiles protocol. |
ui/src/Components/PredictionEditor/PredictionEditor.jsx |
Implements the editor screen. |
ui/src/Components/PredictionEditor/PredictionEditorRightPanel.jsx |
Implements editing controls. |
ui/src/Components/PredictionEditor/predictionClassify.js |
Implements classification logic. |
ui/src/Components/PredictionEditor/predictionClassify.test.js |
Tests UI helper behavior. |
ui/src/Components/PredictionEditor/predictionPrep.js |
Implements polling state machine. |
ui/src/Components/PredictionEditor/predictionSwipe.js |
Implements swipe decisions. |
ui/src/Components/ProjectManagement/EmbeddingModelRow.jsx |
Adds embedding Edit action. |
ui/src/Components/ProjectManagement/ModelResultsButton.jsx |
Adds inference Edit action. |
ui/src/Components/keyboardShortcuts.js |
Extends shortcut handling. |
ui/src/util/pmtiles.js |
Provides shared PMTiles plumbing. |
Suppressed comments (3)
api/hastefuncapi/function_app.py:2964
- The supplied image layer is loaded independently of the model and never checked against
model_data.imageLayerId. Because predictions and footprints are joined positionally, a valid same-project layer ID can make this session expose unrelated geometry as editable model predictions (and equal row counts will not catch it). Reject a mismatched model/layer pair before reading the GeoPackages.
image_layer_data = await asyncio.to_thread(
api/hastefuncapi/function_app.py:3104
- This preparation route accepts any same-project image layer without verifying that it is the model's layer. A mismatched request can build and persist an attribute sidecar by position against unrelated footprints, corrupting the editor's model-to-building mapping. Validate
model_data.imageLayerId == prep_request.imageLayerIdbefore queueing.
image_layer_data = await asyncio.to_thread(
api/hastefuncapi/function_app.py:3230
- The save path also trusts an independently supplied image layer. If another layer happens to have the same footprint count, edits are written with those unrelated Overture IDs and pass the row-count guard. Reject the request unless
edit_request.imageLayerIdis the layer recorded on the model before downloading either artifact.
image_layer_data = await asyncio.to_thread(
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| model nor the layer already has a usable archive. | ||
| """ | ||
| needs_pmtiles = not bool(resolve_tiles_url(model, image_layer)) | ||
| needs_attrs = not bool(model.predictionAttrsUrl) |
| image_layer_id = req.params.get( | ||
| "imageLayerId" | ||
| ) or model_document.get("imageLayerId") | ||
| if not image_layer_id or not _GUID_RE.match(str(image_layer_id)): | ||
| return _bad_request( | ||
| "Invalid or missing parameter: imageLayerId" | ||
| ) |
| if ( | ||
| self.image_data.status | ||
| == self.config.get_status_types().COMPLETED.value | ||
| ): | ||
| self._enqueue_footprint_tiles() |
| def _detect_flavor( | ||
| layer_name: Optional[str], | ||
| field_names: List[str], | ||
| damage_values: List[float], | ||
| ) -> str: | ||
| """Classify which producer wrote a prediction layer. | ||
|
|
||
| A ``predictions`` layer carrying an ``area`` column is the | ||
| interactive labeler's output. Failing that, a ``damage_pct_0m`` | ||
| column that only ever holds 0.0 or 1.0 is the labeler's degenerate | ||
| copy of ``damaged`` rather than a real damage fraction. | ||
| """ | ||
| if layer_name == EMBEDDING_LAYER_NAME and AREA_FIELD in field_names: | ||
| return EMBEDDING_FLAVOR | ||
| if damage_values and all(v in (0.0, 1.0) for v in damage_values): | ||
| return EMBEDDING_FLAVOR | ||
| return INFERENCE_FLAVOR |
| except Exception as e: | ||
| logger.error( | ||
| "PreparePredictionTilesQueueTrigger: Error processing queue " | ||
| f"message: {e}\n{traceback.format_exc()}", | ||
| stack_info=True, | ||
| ) |
e718641 to
7a7404c
Compare
RC artifacts readyAll branch deployment references use the same RC tag:
|
2e47880 to
10e428c
Compare
RC artifacts readyAll branch deployment references use the same RC tag:
|
RC artifacts readyAll branch deployment references use the same RC tag:
|
Every model row in both workflows gains an Edit button that opens a new screen showing all predicted building footprints. An analyst can reclassify buildings and, for trained-inference models, re-threshold on damage_pct_0m, then save the result as a new version of the prediction geopackage. The button stays disabled until a full prediction set exists: inferenceStatus == "Processed" for a trained model, and a non-zero predictedBuildingCount for the embedding workflow. That count is new — "Clear labels" also writes a geopackage and sets gpkgUrl, so the old !!gpkgUrl check could not tell a completed pass from a cleared one. Reads go through PMTiles plus a columnar attribute sidecar, prepared by a queued job that runs in the training image because tippecanoe only exists there. Nothing before this could deliver a full prediction set to the browser: the GeoJSON route returns a random sample capped at 2000, and vector tiles existed only for the embedding workflow. The threshold slider recolours from the sidecar in the browser, so it costs no server round-trip. Writes never touch the raw output. The client sends a threshold and a sparse override list; the server re-reads the source geopackage, applies threshold then overrides, and writes a new numbered version. Model.gpkgUrl still points at the raw prediction, and each version is recorded in Model.editedPredictions. Row order is preserved exactly, because two downstream consumers join predictions to Overture ids positionally; an explicit overture_id column is now written alongside. The two producers disagree on schema — trained inference writes a continuous damage_pct_0m in the raster CRS, while the embedding endpoint writes layer "predictions" with a degenerate 0/1 copy — so a normalising reader hides the difference and thresholding is offered only where it is meaningful. Also fixes a latent conflict: the Interactive Labeler registered its own pmtiles protocol handler, and addProtocol keeps one handler per scheme, so a second registration from the editor would have silently broken its tiles. Both now share a singleton. Specs and an ADR for versioned derived artifacts are included, and the stale API reference is brought up to date.
Preparing an embedding model for editing failed, and would have wasted a multi-gigabyte container job even once it worked. Reuse the tiles we already have. The embedding workflow tiles the same footprints from the same PMTiles archive, keyed on the same integer row-index id, and records it as Model.pmtilesUrl. needs_preparation only looked at ImageLayer.footprintPmtilesUrl, so it rebuilt a byte-for-byte equivalent archive. Preparation now resolves the model's own archive first, and GetModelArtifact serves footprint_pmtiles from it, which also means that kind no longer needs an imageLayerId for embedding models. Stop importing the queue SDK inside the training image. The workflow stored its outputs through ArtifactProcessor, which also drives zip jobs and therefore imports azure.storage.queue — a package the training image does not install, so the job died with ModuleNotFoundError after all the real work was done. It now talks to UnifiedArtifactStorage directly, constructed exactly as ArtifactProcessor does; putting bytes in blob storage is all this workflow ever needed. Verified against a real embedding model in the local stack: preparation reports tilesReady immediately, the job reaches Processed, and the sidecar serves 1496 buildings with every column the same length.
Build the layer's footprint PMTiles as part of image-layer creation, so a trained-inference model opens straight into the editor instead of waiting on a tiling job. Tippecanoe lives only in the training image — not in imageryprep and not in the Functions app — so the tiling itself stays a queued job; imagery prep simply enqueues it once the footprints are cached. That needed a layer-only mode for the prep job. modelId and the predictions URL are now optional: without them the job builds the PMTiles, skips the sidecar, and records status on the image layer rather than the model. The message schema is unchanged, so the queue contract stays backward compatible, and the on-demand path still tiles older layers when someone opens the editor. Layer-time status writes patch only the tiling fields, because imagery prep is writing the same document around the same time. Enqueue failures are logged and swallowed: tiles are an optimisation, and the editor can still build them on demand. Add a swipe comparison to the editor: pre-event against post-event, or basemap against post-event when the layer has no pre-event imagery. atlas.SwipeMap reveals the PRIMARY map on the left and clips the SECONDARY, so the comparison map is primary and the editor map is secondary — moving the divider left uncovers more of the editing map. A recent PR shipped that description inverted, so a test pins the wording. Editing is wired to both panes and feature-state and paint expressions are mirrored across renderers; otherwise half the map is inert and the far side draws every footprint unlabeled. A/S/D move the divider, consistent with the other map views. Verified in the local stack: a layer-only job runs tippecanoe for real and lands footprintPmtilesUrl, and the tiles serve as valid PMTiles.
Review asked for prediction editing to be a mode inside the existing View Results page rather than a screen of its own. Doing that surfaced why the two workflows had never really shared a results story. The viewer was raster-only: a pre-coloured visualizer COG plus a raw predictions raster, both produced solely by the train/infer workflow. The embedding workflow makes no rasters at all, so pointing it at the viewer would have shown an empty map — and no code path anywhere rendered per-building predictions, for either workflow. So the viewer now draws predicted building footprints as vectors from the footprint tiles and the attribute sidecar, artifacts both workflows already produce, and the rasters become optional layers that simply do not appear when a model has none. That is what lets both workflows produce mostly the same results and be handled the same way. Editing rides on that same layer. A pencil beside Back, or E, turns the footprints interactive: click to set a class, ctrl+drag to box-select, right-click to clear an override back to the derived class, with the threshold slider shown only where re-thresholding means anything — the embedding workflow's damage fraction is a degenerate 0/1 copy. Leaving edit mode restores the read-only view and confirms before discarding unsaved work. The standalone screen, its route and both model-row Edit buttons are gone, and with them the logic that existed only to disable those buttons. The embedding row gains the View Results it never had. Three places disagreed about whether a model has results, so there is now one rule in hastegeo, surfaced as predictionsReady, and publishing uses it too rather than keeping a fourth copy. Reads resolve their source through resolve_prediction_source, so an analyst's edits finally reach the reports instead of being written and then ignored; the newest version wins and an explicit version can be requested, with no mutable "active version" pointer, which ADR-0005 deliberately avoided. Guards the null rasters this makes possible: four raster loaders dereferenced fields that are now absent for embedding models, and one older payload shape handed them a tile template with an empty url that would have quietly served 404s rather than failing.
Saving an edit produced a version nobody could reach. The history was rendered only inside the edit panel, so a read-only viewer could not see that versions existed, nothing ever refetched to draw a different one, and every download button fetched the raw output regardless. Switching was not simply unwired. The attribute sidecar the viewer draws from is keyed per model, not per version, so it always described the raw predictions; saving wrote a GeoPackage but no sidecar. There was nothing version-specific to render. Every saved version now gets its own sidecar, written in the same call as its GeoPackage so the two cannot drift, which makes drawing a version the same code path as drawing the raw output with a different URL. The builder moves out of the training workflow into a shared module so the API can produce one without importing an image it has no business importing. Versions saved before this are backfilled by the tiles job, idempotently and skipping any that already have a sidecar. A version still waiting is offered as disabled with the reason, and the raw sidecar is deliberately never substituted for a missing one — quietly drawing the wrong classes is worse than drawing nothing. The selector sits in the read-only view, and switching rebuilds the source, layers and feature-state on both swipe panes. Feature-state is per-renderer and the source id is reused, so state is cleared before the source is removed; otherwise one pane keeps the previous version's colours, which is how this repo has twice shipped a half-updated map. Selection moves the map only — the reports keep reading the newest version — so the screen says when the two diverge rather than leaving it to be discovered. Downloads go through the artifact route, which already handles auth and ranges, instead of rewriting storage URLs. Also fixes a data-loss hazard the switching exposed: saves always derive from the raw GeoPackage, so saving on top of a loaded version had to resend that version's classes or it would silently discard the edits it was built on.
…k zoom Editing a footprint's class went through two controls that overlapped: a "Clicking a footprint" radio group whose default was a cycle mode, and a separate row of buttons that set the *selected* building's class. The two were coupled — pressing 1/2/3, or using the buttons, silently changed the click mode as a side effect — so what a click would do depended on state the analyst had changed several actions ago. Replace both with one class picker. The picked class is what every edit gesture applies: left-click a footprint, or Ctrl+drag to box-select many. Right-click still undoes an edit. The cycle mode is gone, along with cycleClass() and the branch that advanced each building from its own class during a box-select. Picking a class no longer edits anything on its own. Relabelling whatever happened to be selected, because the analyst pressed a number key to line up their next click, is the surprise this mode should not have; Enter is now the explicit "apply to the selected building" for the arrow-key review flow, and is listed in the shortcut help. normalizeEditClass() guards the one-way door: every path that writes an override now funnels through it, so a stale value (the old "cycle" sentinel, say) cannot reach the overrides map and, from there, a saved GeoPackage. Also disable Azure Maps' double-click zoom for as long as edit mode is on. Labelling two neighbouring buildings quickly registers as a double-click, and the map zoomed out from under the analyst mid-edit. Panning and scroll zoom are untouched, and the gesture is restored on exit and on unmount.
ef36731 to
a26c74d
Compare
The model rows downloaded the raw GeoPackage unconditionally, and the two report modals took whatever the server picked. Once a model has saved edits that is a silent choice made on the analyst's behalf: they correct predictions, download the file, and get the model's uncorrected output back with a filename that gives no hint which one it is. Both places now ask. Download: when a model has saved versions the row opens a small dialog to pick one, defaulting to the newest, and streams it through GetModelArtifact so the filename carries the version (_v1). A model nobody has edited has nothing to choose, so it downloads directly as before and never sees the dialog. Reports: Validation and Assessment each get a "Report on" dropdown, defaulting to the newest saved edit — the same rule the server applies when no version is passed, so the default is what these modals already showed. Both endpoints already accepted `version`; this only gives the UI a way to send it. predictionSourceOptions is deliberately not versionSelectorOptions. That one disables a version whose attribute sidecar has not been backfilled, because the map cannot colour buildings without it. A download reads the GeoPackage, where the sidecar is irrelevant, so reusing the map's rule would have hidden perfectly good files behind "preparing…". A test pins the difference. Known gap, unchanged by this commit: Validation reads the `damaged` column, which apply_edits rewrites, so it follows the selection. Assessment counts from `damage_pct_0m`, which apply_edits leaves at the model's original score, so its headline counts do not move between versions yet.
RC artifacts readyAll branch deployment references use the same RC tag:
|
Assessment read damage_pct_0m, which apply_edits deliberately leaves at whatever the model predicted, so its headline counts were identical for every version of a model. Picking "Version 1" in the new report dropdown changed nothing on screen: model 5553 reported 740 predicted-damaged buildings whether or not you selected the version whose own sidecar says 770. build_assessment_inputs_from_gpkgs now reads edited_class when a row carries one and falls back to the score otherwise, so raw predictions behave exactly as before. "Unknown" is recorded as full unknown coverage, which is what already excludes a building from the known population and from the damaged count. Verified against the dev stack: model 5553 now reports 740 for the raw output and 770 for v1; model 0448, which has six edits, moves 2601 -> 2595. Both match the per-version attribute sidecars. There is no continuous score behind a human decision, so edited rows come through as 0.0/1.0 and an edited version's precision-recall curve is a single binary operating point rather than a sweep. That is a real change in what the curve means, and it is the honest one: the alternative was ranking buildings by a score the analyst has already overruled.
RC artifacts readyAll branch deployment references use the same RC tag:
|
| if (hasPredictionVersionChoice(model.editedPredictions)) { | ||
| setShowDownloadPredictions(true); | ||
| return; | ||
| } | ||
| handleDownload(model.gpkgUrl); |
| const result = await footprints.save(); | ||
| setDialog( |
| switching versions only changes a URL. ``predictionVersionIsLatest`` | ||
| says whether that version is the newest saved one: version selection | ||
| moves the map only, while the Assessment/Validation reports always | ||
| read the newest version, and the UI has to be able to say when the | ||
| two diverge. A version whose sidecar has not been built yet reports | ||
| ``predictionsReadiness.attrsReady = false`` (reason ``preparing``) |
RC artifacts readyAll branch deployment references use the same RC tag:
|
…tons The version selector sat at top: 66px, a row of its own below the Back/Edit controls, leaving an obvious band of empty map between the top of the view and the first control. It now shares the top row with the corner blocks. Below 1100px a 560px centre column plus both corners stops fitting side by side, so it drops back to its own row there rather than sliding under them — the corners are z-index 1000 and it is 950. The edit panel's scroll container is a flex column, so every block inside it was free to shrink once the content was taller than the panel. Buttons lost the top and bottom of their own labels instead of the column simply scrolling; "No manual edits" was the clearest case. Direct children of the scroll area and of the button stacks no longer shrink.
Footprint geometry belongs to the image layer: every model trained on a layer draws the same buildings. Two jobs were tiling it anyway. Imagery prep builds the layer archive when the footprints are cached, and the embedding job built its own per-model copy on top of that. The two archives were the same file. Both ran tippecanoe with the same layer name, the same --use-attribute-for-id=id, the same -y id -y overture_id, and the same zoom range, over the same rows in the same order (embed_buildings reads the layer footprints, reset_index, and keeps every row including the ones with no valid tokens). So an embedding layer paid for a byte-for-byte duplicate. The embedding workflow no longer tiles. tippecanoe and subprocess drop out of it entirely, and it emits the embeddings GeoJSON, the HFTR feature sidecar and the manifest. The feature sidecar is per model and stays exactly as it was. With one archive, resolve_tiles_url stops choosing between a model field and a layer field, Model.pmtilesUrl and ArtifactTypes.BUILDING_PMTILES retire, and GetModelArtifact loses both the "pmtiles" kind and the branch that reused a model's archive for a layer-scoped request. The Interactive Labeler reads kind=footprint_pmtiles instead. Existing embedding models keep a stale pmtilesUrl in their stored metadata. Nothing reads it, and re-tiling old layers is out of scope by agreement. The labeler used to refuse to start when a model had no archive. That guard is gone with the field, so a failed tile load now raises a clear error instead of warning to the console and leaving an empty map with no buildings to label.
fec2de9 to
af16bc4
Compare
RC artifacts readyAll branch deployment references use the same RC tag:
|
RC artifacts readyAll branch deployment references use the same RC tag:
|
Analysts can correct a model's predicted building damage, save the result as a numbered version, and then choose which version they view, download, and report on. Both HASTE workflows produce the same kind of result, so the viewer and the reports treat them identically.
What changed
Editing happens inside the existing View Results page. Press the pencil button, or
E, and the page switches to edit mode over the same map. There is no separate editor screen and no per-row Edit button, which also removes the logic that used to disable that button when footprints were missing.Pick a class from the panel, then click a footprint to apply it. Ctrl+drag paints every building in a box, right-click restores one to the model's own call, and Enter applies the class to the selected building so the arrow-key review loop never needs the mouse. Trained-inference models also get damage and unknown sliders, which recolour the map in the browser without a server round-trip. Embedding models have no sliders, because their damage column is a 0/1 copy of the predicted class and a slider over it would only ever produce the same two groups.
Saving writes a new numbered version.
Model.gpkgUrlstill points at the raw prediction and versions accumulate inModel.editedPredictions, so the model's own output is never overwritten.A dropdown on the results page switches the map between the raw output and any saved version. Downloads go through
GetModelArtifactso the filename records which one you got, for examplebuilding_predictions_5553_v1.gpkg. The model rows ask the same question: when a model has saved edits, the download action opens a small dialog instead of quietly handing back the raw file. The Validation and Assessment modals each gained a "Report on" dropdown that defaults to the newest saved version.The embedding workflow gained a View Results entry point. It has no raw model prediction layer, but everything else matches.
Why parts of this were harder than they look
!!gpkgUrlcould not gate the embedding workflow. The Interactive Labeler's "Clear labels" action PUTspredictions: [], which writes a valid GeoPackage and setsgpkgUrl, so a cleared model looked exactly like a finished one.PutBuildingPredictionsnow storespredictedBuildingCountandpredictedAt, which it already computed and returned but never persisted. One helper,model_readiness, holds the rule so the results button, the embedding row, and the publisher cannot drift apart again.Nothing could deliver a full prediction set to the browser.
GetBuildingFootprintsGeoJSONreturns a random sample capped at 2000 rows, and vector tiles existed only for the embedding workflow. Trained-model predictions reached the UI as a pre-coloured raster and nothing else, which is why an embedding model had no viewer at all. Reads now go through footprint PMTiles plus a columnar attribute sidecar, and the browser colours each building through map feature-state.Tiling cannot run in the API.
tippecanoeships only in the training image, so preparation is a queued job on a newprediction-edit-prep-queue. Layer footprints are tiled once at layer creation and shared by every model on that layer. The embedding workflow already tiles the same footprints with the same row-index ids, so those tiles are reused rather than rebuilt, and only the attribute sidecar is generated on first open.Row order is load-bearing. Predictions join to Overture ids positionally against the footprints GeoPackage. The writer preserves row order exactly and now emits an explicit
overture_idcolumn. A count mismatch raises instead of silently misaligning, which would have mis-coloured buildings with no visible symptom.Each version needs its own sidecar. The attribute sidecar was keyed per model, so it always described the raw predictions and switching versions had nothing to render. A per-version sidecar is now written in the same call as the version's GeoPackage, and older versions are backfilled by the same queued job.
Assessment counted the model's score, not the analyst's call.
apply_editsrewritesdamagedandedited_classbut deliberately leavesdamage_pct_0mat whatever the model predicted. Assessment counted from that score, so every version of a model reported identical numbers: model 5553 reported 740 predicted-damaged buildings even when you picked the version whose own sidecar says 770. It now readsedited_classwhen a row has one. No continuous score sits behind a human decision, so edited rows come through as 0.0/1.0, and an edited version's precision-recall curve is a single operating point rather than a sweep. Raw predictions are unaffected.Feature-state is per renderer. The swipe map is two Azure Maps instances, and feature-state has to be cleared before the source is removed because the source id is reused. This class of bug shipped twice during the build.
Incidental fixes
InteractiveLabelerregistered its ownpmtilesprotocol handler.addProtocolkeeps one handler per scheme, so a second registration would have silently broken the labeler's tiles. Both now share a singleton inui/src/util/pmtiles.js.Double-click zoom is off while edit mode is on. Labelling two neighbouring buildings quickly registers as a double-click, and the map zoomed out from under the analyst mid-edit. Panning and scroll zoom still work.
The edit panel's scroll container is a flex column, so its children were free to shrink once the content grew taller than the panel. Buttons clipped the top and bottom of their own labels instead of the column scrolling.
The opacity, contrast, hue and saturation panel is gone from the results page, along with the state and handlers that only fed it. The layers keep the defaults the panel's reset button applied. The labeling tool has its own separate copy of those sliders, untouched.
Verification
hatch run test:pytestnode --test)vite buildmain's baseline; 0 problems in new filesblack/isort/flake8All 47 Python failures also fail on
main. Forty-six come from thehastegeo[planetary-computer]extra missing in the local conda environment, which CI installs, and the last is the pre-existingtest_artifacts.py::test_zip. The failure sets were diffed againstmainrather than compared by count.Version selection, downloads and the backfill were exercised against the local docker stack. Model 5553 reports 740 predicted-damaged buildings on the raw output and 770 on v1. Model 0448, which has six saved edits, moves from 2601 to 2595. Both match the per-version sidecars. Unknown versions return 404 on the artifact and report routes, and re-running preparation returns
queued: false.Known limitations
There is no 409 on concurrent saves.
next_versionplus save is a read-modify-write with no optimistic concurrency, so two simultaneous saves could collide. The UI already handles a 409 if one is added later.There are no browser tests. This repo has no Playwright config, so the map interactions, the version dropdown, dark-mode contrast and the prepare-to-ready transition were checked through the API and the rendered bundle rather than a real browser. The 1100px breakpoint where the version bar drops to its own row is an estimate and worth a look at a real window width.
The selected version is not in the URL, so a version cannot be shared or bookmarked.
Bicep app-setting parity is skipped for the new queue.
Configresolves the default and the Functions host creates the queue on first write, and editingfunctions.bicepwithout regeneratinginfra/main.jsonwould cause drift.API-level integration tests are absent. Coverage sits at the processor and model level;
api/hastefuncapi/tests/currently holds onlytest_publishing_routes.py.New routes
GetPredictionEditSession,PutEditedPredictions,GetEditedPredictionVersionsandPutPreparePredictionTilesQueueMessage.GetVisualizerResultsandGetModelArtifacttake an optionalversion, as doGetValidationReportandGetAssessmentReport.Spec
spec/features/prediction-editing/, plus ADR0005-versioned-derived-prediction-artifacts.md, the first artifact-versioning concept in this codebase. The spec was reconciled against the final implementation, including the routes that changed shape during the build.