Commit Graph
836 Commits
Author SHA1 Message Date
JensandClaude Opus 5 7c052a339e keep a precise failure precise, and state one redirect policy
Two problems of the same shape: information about *why* something failed being
replaced by something vaguer.

get_dataset_geojson wrapped the JSON parse, the metadata read, the CRS
resolution and the canonicalisation in one try and reported all of it as
"Stored dataset is not valid JSON" with a 500. An operator whose dataset had an
unusable CRS was sent to inspect a file that parses perfectly well, and the
canonicaliser's own AppError — with its code and its status — never reached
them. Only the parse is now inside that handler; everything after it keeps the
error it raised, and a genuine bug becomes a distinct 500 rather than a
mislabelled client error. A guard finds the same shape elsewhere: catching
Exception around a call into another component and relabelling what it
reported. Wrapping one's own private helper stays legitimate and the guard
says so.

The redirect policy was split without anyone saying so. Two acquisition
services rejected every redirect through a hand-rolled opener, while eight
allowed a same-origin one through the shared guard — and only the latter
checked where the response came from. Both live in the guard now, and the
strict path uses the rejecting handler rather than the guard's after-the-fact
check: objecting to response.url means urllib already opened the connection and
read the body, which for a metadata endpoint is the whole attack. That was a
weakening I introduced in this same commit's first draft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:35:59 +02:00
JensandClaude Opus 5 c6837ec1b2 split the map workspace into a view model and two views
MapWorkspace.tsx was 3.157 lines: a props interface, 1.200 lines of derived
state and handlers, and two complete render paths — the map-first explorer and
the advanced workbench behind it. It is now five modules, and the container is
nineteen lines that choose between the two.

The obstacle was the props signature. The explorer reads 97 derived values and
the workbench 40, so passing them individually would have produced a 97-field
interface — worse than the file it replaced. Extracting the derived state into
a hook that returns one object solves it: MapWorkspaceViewModel is
ReturnType<typeof useMapWorkspaceViewModel>, so the shape is derived from what
the hook actually produces and cannot drift from it. Each view then names two
typed objects, and the JSX moved unchanged.

The contract tests found the one place where widening a negative assertion is
wrong. "The map workspace performs no transport" was true of the old file and
false of the whole feature, because the hooks call the API by design. It is now
scoped to the presentational modules, which is what it always meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:38:25 +02:00
JensandClaude Opus 5 c4d873149b page the analysis run listings
Detection and segmentation run listings returned every run a project had ever
produced. Runs accumulate with every analysis while the panel only ever draws
the recent ones, so the response grew without bound for no benefit.

Both take limit and offset now and report total, limit, offset and truncated,
matching the result listings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:18:08 +02:00
JensandClaude Opus 5 6572e4ad5f scope frontend contracts to the feature, not to one file
93 test files read a single frontend source and asserted identifiers in it. The
MapWorkspace split showed what that costs: 24 tests went red for a move that
changed no behaviour at all. A contract belongs to the feature — a container,
its hooks, its domain layer — not to whichever file currently holds it.

232 read sites now resolve through read_feature(). The distinction that makes
this safe is direction: a *positive* contract ("this is wired") may widen,
because the identifier must still exist somewhere in the feature; a *negative*
one ("this component performs no transport") is a statement about one file, and
widening it would quietly weaken the check. The 73 single-file reads that
remain are exactly those, and a guard now enforces the rule for new tests.

Verified rather than assumed: of the 732 migrated positive assertions, 644 still
match exactly one module — as specific as before — and the other 86 already
spanned a container and its hook by nature. Two apparent misses are an artefact
of the checking regex reading an escaped newline literally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:05:43 +02:00
JensandClaude Opus 5 a2a8775df1 let segmentation honour its post-processing configuration
Segmentation reuses the detection suppressor but passed only the IoU threshold,
so it silently fell back to the hardcoded containment constant while detection
had just been given a configured one. Tuning containment for a promoted model
would have changed detection behaviour and left segmentation on the old value —
the same drift, one commit later.

Masks and boxes overlap differently, so segmentation carries its own setting
rather than borrowing the detector's, and records both thresholds on the run as
detection does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:45:33 +02:00
JensandClaude Opus 5 c8d32a4801 serve a promoted model at the post-processing it was gated on
scripts/evaluate_belgium_building_candidate.py freezes its post-processing
before the protected test — NMS IoU and a containment threshold selected during
calibration, defaulting to 1.0. The runtime applied a hardcoded 0.85, so a
promoted candidate was served under suppression its evaluation never measured
and dropped detections the gate had counted. Neither report showed the
difference. That constant was mine, added without noticing the evaluation
pipeline already had a tuned value for the same concept.

Containment is now configuration, recorded on every run beside the duplicate
IoU threshold, so an operator can serve a candidate at the value it was gated
at and afterwards see which value produced a given score.

Two runs that post-processed differently produced different candidate sets from
the same model output, so the comparison endpoint refuses to rank them. Runs
recorded before those values were persisted carry none, and absence is not
treated as a difference.

The remaining gap is deliberate and documented rather than closed: the gate
scores the model on its protected test set, the workbench scores the whole
pipeline including coverage clipping and the tile-edge filter. A promoted
candidate will not reproduce its gate F1 exactly, and pretending otherwise
would be the worse answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:15:05 +02:00
JensandClaude Opus 5 1a1a9af6e7 give segmentation QA the same threshold-independent view as detection
Detection QA reports a precision/recall curve, average precision and a
calibration sweep; segmentation QA reported a single operating point. Both rank
their outputs by confidence, so the same view applies, and the asymmetry meant
the two panels answered different questions about comparable runs — an
inconsistency introduced when detection gained the curve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:47:30 +02:00
JensandClaude Opus 5 ff4a15aa74 let an operator's adjudication reach the score
The review vocabulary already separates a model error from a reference gap,
because the product's position is that official footprints are not
automatically perfect ground truth. Those verdicts were only counted. An
operator who inspected forty false positives and established that twelve are
buildings the reference simply lacks still saw a precision counting all forty
against the model — a number they had personally disproved, on the panel where
they disproved it.

Applying the verdicts gives an adjudicated score reported next to the raw one,
so nothing is quietly improved. Not being able to judge is not evidence in the
model's favour, so uncertain and obscured verdicts keep counting, as does a
decision from a later release that this runtime does not recognise.

Because part of the evidence is usually still unreviewed, the honest form is an
interval rather than a single corrected number: pessimistic assumes every
unreviewed finding is a model error, optimistic assumes none is, and the
headline equals the pessimistic reading so a partly reviewed check never
presents as a settled one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:43:22 +02:00
JensandClaude Opus 5 f6eced1b94 rank model variants on average precision, and say when they are not comparable
The workbench ranks model variants by a stored F1, each measured at that
variant's own confidence threshold. A conservatively calibrated detector then
looks worse than a liberal one without detecting anything differently: the
number says as much about the threshold as about the model. POST
/detection/runs/compare ranks on average precision instead, which describes the
whole ranking a model produced, and keeps each run's own-threshold F1 visible
next to it so the difference between the two readings is auditable.

Comparability comes before the ranking. Runs over different source rasters,
scored against different references, without a proven inference footprint, or
covering a different evaluated population are not alternatives to one another,
and no metric makes them so. The report names which of those applies and still
returns the numbers — they are simply not a ranking.

Each run is scored through the same QA path the workbench uses, so a comparison
and the persisted quality checks cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:47:14 +02:00
JensandClaude Opus 5 2cd2c49389 calibrate a confidence threshold from one inference pass
Threshold calibration ran the model over every tile once per threshold — three
GPU passes to compare 0.50, 0.25 and 0.15 on a hundred-tile raster. The answer
is already in a single run at the lowest value: detections above a higher cut
are a subset of it, and duplicate suppression walks candidates in descending
confidence, so a lower-confidence box can never displace a higher-confidence
one. The kept set above any cut is identical whichever threshold the run used,
which is what makes one pass sufficient rather than merely cheaper.

QA now takes calibration_thresholds and reads each operating point off the same
precision/recall walk it already performs, marking the F1-optimal cut. The lab
runs inference once and fills its table from the sweep.

The contract test asserted the per-threshold loop by name, pinning the waste it
was meant to describe. It now states what calibration owes an operator: a row
per requested threshold, from one run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:37:19 +02:00
JensandClaude Opus 5 8a26007281 extract and test the four-step GIS workflow
Extracted for its error paths, not its size. Every step can fail or return
nothing, and each outcome has to leave the operator with a status that says
where the chain stopped — a workflow reporting "afgerond" after a step produced
nothing tells them a result exists when it does not. Inline in the component,
none of that was exercised; it now has fourteen tests covering each stopping
point, both rejection kinds, the reuse path and the preconditions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:08:44 +02:00
JensandClaude Opus 5 6177eecef5 extract and test the map rectangle interaction state
Corner tracking for a two-click rectangle lived inline in the component and
was never exercised. The rule worth pinning is that a first corner yields no
rectangle at all: acting on it would analyse a zero-width area.

The hook holds only interaction state — drawing mode, placed corner, the
coordinate fields. What happens with a finished rectangle stays in the
workspace, which owns retiring stale results and starting the analysis.
Injecting that would have made the hook depend on values declared after it,
which is what a first attempt at a wider extraction ran into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:01:45 +02:00
JensandClaude Opus 5 9c29577b82 collapse five duplicate overlay blocks into one tested builder
MapWorkspace held five near-identical useMemo blocks deciding which raster
image the map draws under the active theme — terrain, flood depth, thematic
raster, WALOUS land cover and bathymetry. Each filtered partitions by source
name, read bbox_epsg4326 and assembled the same overlay shape, so the parts
that genuinely differ per theme were buried in the repetition.

One builder makes the rule testable and leaves only the source, the label and
the opacity varying. A raster whose bounds are unusable is now skipped rather
than drawn from a partial bbox: an overlay in the wrong place is worse than no
overlay.

The legend asked "are these thematic or WALOUS overlays" by inspecting two of
the five lists. That is a property of the source, so it says so directly.

Two contract tests needed fixing rather than repointing. One asserted
`"api" not in source.lower()`, which the new hook name useMapImageOverlays
matches inside "useM-api-mageOverlays" — as would rapid, capital or therapy.
The contract is that this component talks to no API client, so it now says
that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:56:21 +02:00
JensandClaude Opus 5 39c12822bc extract the map workspace's domain layer out of the component
MapWorkspace.tsx opened with ~590 lines of theme catalogue, dataset matching
and label formatting above a 3.200-line component. None of it is React, all of
it is independently testable, and both render paths read from it, so it belongs
beside the pure helpers that already live in mapWorkspaceUtils.

The contract tests that read MapWorkspace.tsx would have gone red for a move
that changes no behaviour at all — 24 of them. That is the brittleness the
frontend_contract helper exists to remove, so it gains read_map_workspace():
the workspace is one feature spread over several modules, and a contract
belongs to the feature rather than to whichever file currently holds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:49:06 +02:00
JensandClaude Opus 5 e2f586c029 consume only artifacts the runtime produced
tile_manifest_path arrives in the detection and segmentation request and was
read straight off disk, and a manifest entry may name an absolute tile path.
That makes an API field an unbounded reference to the host filesystem, and it
contradicts the rule the persistence model rests on: only a governed,
runtime-produced artifact may be consumed, and a file outside the storage root
is not one.

Both the manifest and every tile it names now resolve under STORAGE_ROOT.
Resolution happens before the comparison, so ".." cannot climb out and a
sibling that merely shares a name prefix does not pass.
GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS opts out for provisioning workflows that
stage tiles before ingest.

The check honours the Settings the caller is operating under rather than the
process-wide ones, because every analysis path already threads its own.

The affected tests write manifests into tmp_path, so they now declare tmp_path
as the storage root — which is what a deployment does, and makes the fixtures
more honest than they were.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:16:25 +02:00
JensandClaude Opus 5 16dedeb670 claim analysis jobs atomically and keep acquisition on its official host
Two defects of the same kind: work that is supposed to be bounded is not.

The analysis worker selected queued jobs and then set them to running in a
second statement. A restarted process overlapping the previous one, or a second
replica, could both select the same row and both start tiled GPU inference on
it — duplicate analysis runs and double the GPU load. The AOI worker beside it
already claims with FOR UPDATE SKIP LOCKED; this uses a conditional update,
which is the same guarantee in one statement. run_once now reports jobs it
actually claimed rather than jobs it looked at.

urlopen follows redirects, so although every acquisition URL is built from
settings and cannot be steered by a request payload, a misconfigured or
compromised upstream could send the runtime to the loopback interface, to
another container on the compose network, or to a cloud metadata endpoint — and
the bytes would then be persisted under an official provenance. That is exactly
the substitution the product forbids. All eight fetch sites now open through a
guard that refuses private, loopback and link-local destinations (resolving the
host first, so a DNS name cannot hide one) and refuses a redirect that leaves
the configured origin or downgrades from HTTPS.

The guard is proven by calling the services' own fetch paths, not by grepping
for the call: every existing acquisition test injects an opener, which bypasses
it by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:59:01 +02:00
JensandClaude Opus 5 5b3839dc89 page detection and segmentation results instead of returning all of them
/detection/runs/{id}/detections and its GeoJSON sibling returned every
persisted detection, as did the segmentation equivalents. A regional run holds
tens of thousands, and these are the endpoints the results table and the map
overlay call after every run.

They now take limit and offset, default to 2.000, and report total, limit,
offset and truncated so the complete population stays visible while what is
transferred does not. The GeoJSON responses carry the same window in a
geointel_result_window foreign member.

Rows are ordered by confidence, so a capped overlay draws the strongest
detections rather than an arbitrary slice, and the lab says how many of how
many are being shown rather than silently presenting a page as the whole run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:24:46 +02:00
JensandClaude Opus 5 5278fcd361 bound the QA evidence overlay and fetch only what it draws
evidence_geojson emitted one feature per false positive, one per false negative
and two per match, with no limit. A regional check of 40k detections against 45k
reference footprints produced well over a hundred thousand features in a single
response, plus one warning string per unresolvable identifier. The endpoint the
entire review workflow depends on therefore failed exactly where review matters
most.

What to draw is now decided before any geometry is fetched, so the query work is
proportional to the result rather than to the size of the check — previously
130k geometries were resolved through an IN clause holding every identifier in
the check, to then discard most of them.

The budget is split between misses and false positives in proportion to their
populations with at least one of each, rather than by strict priority, which
would mean a check with 50.000 misses and three false positives never showed
one. Confirmations fill what remains, and a match is kept or dropped as a pair
because half a match is not reviewable evidence.

limit_evidence and evidence_role_counts are removed: plan_evidence supersedes
them, and helpers kept alive only by their own tests read like a contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:14:26 +02:00
JensandClaude Opus 5 52c2bfd120 document selection-bounded change detection and export provenance
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:56:38 +02:00
JensandClaude Opus 5 9614307669 send the map selection to change detection and show the modified class
The comparison hook runs before the selection state is declared in App, so the
selection is read through a getter at the moment the run starts rather than
captured at render. The panel gains the modified count, which until now was
folded into removed plus added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:56:37 +02:00
JensandClaude Opus 5 ea0e401690 skip the selection-edge query where nothing reads it
The temporal timeline summarises every snapshot in a series. Each summary now
also counts how many features the selection edge cuts, but a timeline point
renders values only, so that was one database round trip per snapshot for a
disclosure nobody sees. Make it opt-out and have the timeline opt out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:56:37 +02:00
JensandClaude Opus 5 0d8f146fc4 make an exported file state its own provenance and limits
The vector selection export caps its features and recorded truncated on the
export record. The file said nothing: an operator downloads
mol-selection.geojson, opens 250 buildings in QGIS where the workbench said
1.400, and has nothing to tell them the difference. For a product whose promise
is that an export is a reproducible result, a file that looks complete and is
not is the sharpest possible violation of it.

Every exported FeatureCollection now carries a geointel_provenance foreign
member — RFC 7946 requires parsers to ignore unknown members, so QGIS and
ogr2ogr are unaffected, and the detection export already used the same
convention for its trust classification. It names the source, the dataset and
its edition, the selection, and whether the file is complete; completeness is
derived from the counts as well as the flag, so a caller that forgets the flag
cannot produce a file claiming to hold everything.

Applied uniformly to the selection, dataset, detection and segmentation
exports, which previously disclosed three different amounts of nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:56:37 +02:00
JensandClaude Opus 5 12aaf1bb4d bound change detection to the operator's selection
Change detection was the one analysis that ignored the selection entirely. It
compared two datasets in full, loaded every feature of both into Python with no
spatial predicate, and — with include_unchanged defaulting to true — returned a
FeatureCollection holding both datasets. For a regional building layer that is
the wrong answer to "what changed here" and a response no browser should be
asked to hold.

It now accepts bbox and area_id, resolved the way every other analysis resolves
them, and loads through an indexed ST_Intersects predicate.

Features are deliberately not clipped to the selection. A change class
describes a whole object: comparing a clipped earlier footprint against an
unclipped later one would report the selection edge itself as a change. Objects
the edge crosses are compared in full and counted in a warning.

The returned geometry is capped by preview_limit, spending that budget on
modified, added and removed before unchanged, while every count still describes
the whole selection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:56:15 +02:00
JensandClaude Opus 5 d29d572e8c document the analysis contracts and correct the job-queue claim
The README's architecture diagram and stack list promised Redis + RQ. There is
no such thing in this codebase: redis_url is a settings field nothing reads,
rq is not a dependency, and the only worker is an asyncio poller over a
Postgres job table. Describe what exists.

API_CONTRACTS and KNOWN_LIMITATIONS gain the rules a reader needs to interpret
a result: deterministic confidence-ranked matching, the precision/recall curve,
the three flood-hazard cell populations, the sub-cell selection fallback, the
whole-feature object count next to clipped area metrics, and the required
manifest CRS. The new limitations are stated as limits rather than buried:
raster analysis has no sub-cell precision, and the object count is not
fractional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:34:33 +02:00
JensandClaude Opus 5 3e4e211fad test frontend wiring instead of frontend formatting
19 tests were failing on main. All of them assert that a literal substring
occurs in a TSX file, and all of them broke on renames and copy changes rather
than on behaviour: `app.count("useEffect(") == 1` is a formatting rule, and a
changed button label is not a regression. 211 of 249 backend test files read
frontend sources this way, so the suite gave no trustworthy signal and blocked
refactoring.

tests/frontend_contract.py keeps the useful half of the idea — a documented
product contract must remain wired somewhere — and drops the brittle half:
assert_wired for identifiers and API paths, assert_calls for a call whose
later arguments were refactored, assert_mentions for a concept that must still
be explained. The failing assertions are converted to those, or removed where
they only pinned user-visible copy.

test_frontend_contract_test_style.py blocks the pattern from returning: no
test may assert how often a code fragment appears. Counting list values or
network calls is unaffected.

This does not migrate the ~190 files that pass today; those encode real
contracts and are a separate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:34:13 +02:00
JensandClaude Opus 5 b146b2143d surface the new analysis caveats in the workbench
The backend now says when a result covers a different area than was drawn, or
when a score belongs to one confidence cut only. None of that helps an
operator while it stays in the response body.

- the raster selection adapters carry the model-coverage and widened-cell
  warnings into the map panel and mark the result an estimate when either
  applies, so an existing warning slot renders them;
- the map workspace shows the selection-edge disclosure next to the object
  count;
- the detection panel shows average precision and the F1-optimal threshold
  beside the single-threshold figures, and the box-versus-footprint
  interpretation when candidates are detector boxes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:33:54 +02:00
JensandClaude Opus 5 23d6e0372b distinguish a redrawn footprint from a demolition, and derive estimate
disclosure from data

Change detection had only added/removed/unchanged, so a building extended by
an annexe dropped below the IoU threshold and was reported twice: once as
removed and once as added. That hides exactly the category a change-detection
product exists to show and inflates both counts. A "modified" class now covers
the band between the modified floor and the unchanged threshold.

Matching also ran as a full cross product with no spatial index, unlike the QA
matcher beside it: two municipal building layers meant hundreds of millions of
geometry intersections. It uses an STRtree and considers larger footprints
first, so a big footprint is not left over after a small neighbour claimed its
counterpart.

The assistant guaranteed honesty about estimated values by rewriting the
model's sentences with regular expressions, which only fires when it
recognises the phrasing the model happened to produce. estimate_disclosures
derives the same statement from the metric metadata, so it holds regardless of
how the answer was worded. The prose substitution stays as a second layer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:33:37 +02:00
JensandClaude Opus 5 dd87a62e8f report what an area selection actually measured
Four ways a selection produced a confident number about a different area than
the operator drew:

Flood hazard divided the inundated cells by every cell in the drawn rectangle,
including cells the VMM raster does not model at all. A selection reaching
past the modelled extent therefore reported a diluted risk share, turning
missing data into an implied absence of risk. Terrain, bathymetry and thematic
raster already divided by valid cells; flood hazard was the outlier. It now
reports the three populations separately, states model coverage next to the
drawn area, and returns a null fraction rather than a zero when nothing was
modelled.

geometry_mask selects a cell when its centre falls inside the geometry, so a
rectangle smaller than one cell — or one landing between four centres —
selected nothing and the analysis returned zeros indistinguishable on screen
from "we looked and there is nothing here". On a 100 m population raster a
40 m rectangle over a city block reported no inhabitants. Selection now falls
back to the touched cells and says that it did, since the answer then covers
more ground than was requested. rasterio.mask applies the same centre rule
when cropping, so that call is widened too; the cells that count are still
decided by the centre rule wherever it selects anything.

The object count treated any feature touching the selection as whole, while
intersection_area clipped it — two headline numbers on one panel describing
different populations. The count stays whole-feature, which is what "objecten"
means to an operator, but now reports how many the edge cuts and is marked an
estimate when it does. The area_weighted_sum branch reuses that same count
instead of issuing its own near-identical query.

Partitioned selection de-duplicated the count on source_feature_id but
returned the raw rows, so a building on a municipal boundary was counted once
and drawn twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:33:19 +02:00
JensandClaude Opus 5 08188005bd correct the tiled inference chain and move runs off the request thread
Tile handling produced results that were wrong before any model quality
question arose:

- orthophoto tiles reached the model through PIL convert("RGB"), which
  truncates the high byte of a 16-bit product and treats a 4-band RGB+NIR
  tile's infrared channel as colour. Tiles are now read with rasterio, the
  visible bands are chosen explicitly, and values are percentile-stretched
  across all three bands together so hue is preserved;
- an object wider than the tile overlap was truncated by both tiles into two
  boxes that barely intersect, so IoU suppression kept both: two false
  positives and one missed footprint per seam building. Suppression now also
  compares overlap against the smaller box, and boxes cut by an interior tile
  edge are dropped in favour of the neighbouring tile's complete view;
- georeferencing fell back to an assumed EPSG:4326 when a manifest carried no
  CRS, producing geometry that renders plausibly in the wrong place. QA
  already refused such a tile; inference now fails closed too.

Segmentation QA scored candidates against every reference feature in the
dataset, so every building outside the inferred tiles counted as a false
negative. It now applies the same persisted tile coverage that detection QA
has always used, including the indexed ST_Intersects prefilter.

Duplicate suppression uses an STRtree instead of the O(n^2) scan, tiles are
predicted in batches of YOLO_BATCH_SIZE (a setting that existed but was never
read), and detection/segmentation runs can be queued through /run-async for a
polling background worker rather than holding an HTTP worker thread for
minutes of GPU work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:32:44 +02:00
JensandClaude Opus 5 2b968b74cf make QA scoring reproducible and threshold-independent
The greedy IoU matcher gave a reference to whichever candidate was offered
first. Row order decided that, and every detection in a run shares one
transaction timestamp, so ordering by created_at left the assignment
undefined: the same QA run over the same data produced different mean IoU,
and the geometry shown to a reviewer as a false positive could be the better
of two detections. Candidates are now ranked by confidence with feature
identity as tiebreaker, which is also the COCO/PASCAL rule.

A single precision/recall/F1 triple describes one operating point, so two
models cannot be compared from it: a conservatively calibrated model looks
worse at a low confidence cut and better at a high one without detecting
anything differently. DetectionMetricsService adds the full curve, average
precision and the threshold where F1 actually peaks.

Also:
- report the population the metrics were computed over, so
  matches + false_positives equals candidate_feature_count even under an
  area filter; raw dataset totals move to the _raw fields;
- state whether candidates are axis-aligned boxes or footprint polygons.
  A box can never reach IoU 1 against a rotated building, so the strict
  score has a ceiling that has nothing to do with detection quality.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:31:21 +02:00
Jens 918ee240d5 fix dark illustration controls
GeoIntel release gates / Compile, test, contracts and builds (push) Failing after 39s
GeoIntel release gates / Python and npm vulnerability policy (push) Failing after 28s
GeoIntel release gates / GIS image, SBOM and container scan (push) Failing after 1m41s
2026-08-21 22:51:18 +02:00
Jens d2494417be fix workbench illustration contrast
GeoIntel release gates / Compile, test, contracts and builds (push) Failing after 23s
GeoIntel release gates / Python and npm vulnerability policy (push) Failing after 21s
GeoIntel release gates / GIS image, SBOM and container scan (push) Failing after 1m48s
2026-08-21 22:48:07 +02:00
Jens ae080a7b23 fix mobile navigation placement
GeoIntel release gates / Compile, test, contracts and builds (push) Failing after 22s
GeoIntel release gates / Python and npm vulnerability policy (push) Failing after 22s
GeoIntel release gates / GIS image, SBOM and container scan (push) Failing after 1m44s
2026-08-21 22:40:35 +02:00
Jens c20e718086 refine responsive workbench gui
GeoIntel release gates / Compile, test, contracts and builds (push) Failing after 22s
GeoIntel release gates / Python and npm vulnerability policy (push) Failing after 24s
GeoIntel release gates / GIS image, SBOM and container scan (push) Failing after 2m10s
2026-08-21 22:35:17 +02:00
Jens 178892fc66 add synchronized dual-display workbench
GeoIntel release gates / Compile, test, contracts and builds (push) Failing after 31s
GeoIntel release gates / Python and npm vulnerability policy (push) Failing after 1m2s
GeoIntel release gates / GIS image, SBOM and container scan (push) Failing after 9m48s
2026-08-21 21:50:40 +02:00
Jens f085891630 fix(security): add secret-scan step to CI (both .gitea and .github variants)
Had a real dependency/container-image Trivy scan but no secret scan.
Adds trufflehog (Marketplace Action) as the first step after checkout
in both the .gitea/workflows/ and .github/workflows/ copies of this
workflow (kept in sync as platform-specific variable-syntax variants of
the same pipeline) - the last gap for this repo to count as
fully-authored.
2026-08-16 14:55:37 +02:00
Jens 4b0cab3ce5 make building results authority-first after V74 evaluation
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-10 04:44:29 +02:00
Jens 98188e0a44 evaluate fresh Flemish remediation training
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-10 03:47:51 +02:00
Jens b068a5e065 evaluate models on fresh regional calibration AOIs
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 22:36:27 +02:00
Jens 116b8e291e block checkpoint evaluation on ancestral exposure
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 22:10:50 +02:00
Jens fd45f37a38 block training-seen checkpoint evaluation
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 21:01:12 +02:00
Jens 48084799c9 correct overlapping checkpoint evaluation
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 20:41:54 +02:00
Jens 2de438b9cc derive cleaner min-4px YOLO corpus
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 19:51:17 +02:00
Jens 736e773fb8 audit YOLO geometry and overlapping validation rows
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 19:36:15 +02:00
Jens fc18e72c7f highlight audited YOLO label relationships
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 19:12:04 +02:00
Jens 72b2cdaae4 audit nested YOLO labels without destructive rewrites
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 18:41:29 +02:00
Jens 7e07fe8486 expand AI-assisted review to full building corpus
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 18:37:08 +02:00
Jens f9a466b221 record explicit AI-assisted AOI review ledger
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 15:28:04 +02:00
Jens 182e72e46d record blocked legacy model provenance migration
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 14:57:33 +02:00
Jens 30f6f707c9 audit building checkpoints and preserve production gate
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
2026-08-09 14:31:43 +02:00