Skip to content

Fix cross-aircraft cluster contamination in dark solver candidates - #295

Merged
jehanazad merged 19 commits into
mainfrom
fix/cluster-contamination
Sep 6, 2026
Merged

jehanazad merged 19 commits into
mainfrom
fix/cluster-contamination

Conversation

@jehanazad

@jehanazad jehanazad commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Pins libs/retina-analytics at offworldlabs/retina-analytics#26, and adds the offline measurement that PR was tuned against.

Why

Read-only audit on the live test droplet, 156 GT-matched dark records:

  • 58–65% of dark solver candidates carried a node that could not see the aircraft; 83/129 of those foreign nodes were in the cone of another aircraft.
  • 31 of the 34 candidates with pre-trim rms > 3 µs were contaminated.
  • The solver trimmed 547/1136 successes, and 33 of 69 trimmed nodes were legitimate.
  • 45% of published dark solves still carried a foreign node; position error p90 4–5 km.
  • Candidate node count was about equal to in-cone node count (5.7 vs 5.9), so contamination substituted wrong nodes for right ones rather than adding them.

What changed

Library (offworldlabs/retina-analytics#26). format_track_pairs_for_solver merged every pairing within 6 km into one solver input and, where a node appeared with two different tracks, kept the louder one. A node's tracker gives one track per aircraft, so that case is two aircraft. And the union-find that builds the cluster is transitive, so "within 6 km" chains: on the 15-node bench scene the baseline emitted 208 solver inputs spanning 19–23 single-node tracks.

  • _partition_cluster replaces the SNR pick and runs on every group. Membership is tested against every pairing already in a sub-cluster: same node-to-track assignment, within merge_dist_km, implied-velocity consistent. That bounds each input's diameter instead of letting it grow with the chain. All sub-clusters are emitted — the solver's gates and the resolve slot arbitrate. Counter cluster_splits.
  • _drop_velocity_conflicts adds pair-level exclusivity on the Doppler-implied velocity, gated on cv_fit is None so the inline-fit path is untouched. It abstains wherever either side has no inference, so it cannot cost the recall the earlier delay-residual assignment cost. Counter track_pairs_superseded.
  • _MERGE_DIST_KM 6.0 → 3.0 (one grid step), chosen by the sweep below.
  • chi2_per_dof stays None when nothing in the cluster was fitted — always, on the deferred path.

Bench (backend/scripts/association_bench.py): a CONTAMINATION metric for --mode track, reported at two points.

  • candidate level: % of emitted solver inputs carrying a node whose contributing track belongs to a different aircraft (or to no aircraft).
  • published level: the same score restricted to solves that cleared every gate and bound to a real aircraft — the population the live audit sampled.

Truth comes from a side channel built before _strip_adsb and never fed to association, matching each detection back to the aircraft whose noiseless (delay, doppler) it is nearest, at a gate ~5σ wider than the simulator's own noise. It cannot come from the frame's own ADS-B list: the simulator writes None there for every aircraft with has_adsb false, and dark aircraft are exactly the population under study.

Plus --merge-dist-km, --pair-vel-exclusive, --merge-vel-consistent, --pair-vel-dv-ms, --pair-vel-dtheta-deg, each defaulting to "leave the library's default alone" so a plain run measures what the library ships.

Route: /api/radar/association/status filed track_pairs_superseded under track_pairs_inline_only, documented as structurally zero in production. The deferred path now supersedes, so that grouping would be a lie — moved to the live block with the new cluster_splits.

Bench: 15-node ring scene, 6 seeds, --mode track --cv-fit deferred

This is the contaminated scene (baseline 55% ghost tracks, clusters to 23 tracks). Every leg is --seed 1 --repeat 6.

leg cand. contam. (mean of seeds) published contam. (mean) foreign nodes/published solve ghost by track (pooled) ghost by solve (mean) real tracks/seed widest input
baseline 31.9% 33.3% 0.80 54.9% 5.95% 3 4 6 6 6 7 23 tracks
partition only, 6.0 km, no velocity 33.8% 32.7% 0.80 56.8% 5.90% 3 4 6 6 6 7 10
+ pair-velocity exclusivity, 6.0 km 32.4% 32.0% 0.76 55.6% 5.93% 3 4 6 6 6 7 10
+ merge-velocity consistency, 6.0 km 31.5% 30.2% 0.70 54.9% 4.58% 3 4 6 6 6 7 10
merge 4.5 km 30.6% 28.2% 0.63 54.3% 4.23% 3 4 6 6 6 7 10
merge 3.0 km (shipped) 28.7% 25.1% 0.52 50.0% 2.85% 3 4 6 6 6 7 10

Per seed, published contamination:

seed 1 2 3 4 5 6
baseline 0% 44% 26% 6% 82% 42%
shipped 0% 30% 20% 7% 72% 21%

Per seed, ghost rate by solve:

seed 1 2 3 4 5 6
baseline 0.0% 6.8% 5.0% 8.2% 9.6% 6.1%
shipped 0.0% 4.9% 3.0% 2.3% 3.9% 3.3%

Per seed, ghost rate by track: baseline 0 56 57 60 62 50, shipped 0 50 54 45 62 46 — better or equal on every seed. Median matched error per seed: baseline 0.95 2.39 1.32 0.91 2.11 1.97 km, shipped 1.02 2.54 1.63 0.94 1.78 1.79 — flat (mean 1.61 → 1.62). Real-track count is identical on every seed at every leg.

The merge-distance sweep is monotone across the whole range with the real-track count flat at each point, which is why 3.0 is taken rather than the 4.5 the change started at.

Bench: 50-node scatter scene (droplet-like), 6 seeds

--nodes 50 --metro gvl --layout scatter.

baseline shipped
candidate contamination (pooled) 20.3% 20.0%
published contamination (pooled) 12.7% 7.6%
published contamination, per seed 30 23 0 0 2 27 20 0 0 0 1 24
foreign nodes per published solve 0.14 0.08
ghost by track (pooled) 23.1% 20.0%
ghost by track, per seed 54 0 50 0 11 9 40 0 40 17 20 9
ghost by solve, per seed 7.3 0.0 5.2 0.0 2.0 0.4 4.1 0.0 1.4 0.6 1.2 0.5
real tracks per seed 6 8 3 5 8 10 6 8 3 5 8 10
median matched error (mean of seeds) 1.36 km 1.37 km

Where this misses the acceptance target, and why

The contaminated_inputs_pct target of ≤ 15% is not met. It moves 31.9% → 28.7% on the ring scene and 20.3% → 20.0% on the scatter scene.

The candidate-level rate has a denominator association itself moves. Breaking one 23-track cluster into its constituent targets plus the false pairings it was hiding emits several inputs where there was one, and a false pairing standing alone scores as 100% contaminated. So the rate barely moves even as the thing it was meant to proxy — what reaches the map — gets substantially cleaner. That is why the published-level score exists, and on it the change is unambiguous: ring 33.3% → 25.1%, scatter 12.7% → 7.6%, foreign nodes per published solve 0.80 → 0.52 and 0.14 → 0.08.

Two per-seed ghost-rate regressions, both on the scatter scene, by track: seed 4 goes 0% → 17% and seed 5 goes 11% → 20%. Those are 1 and 2 false tracks against 5 and 8 real ones. The same seeds improve by solve (0.0 → 0.6%, 2.0 → 1.2%), so the extra false tracks are short-lived rather than persistent, but the requirement was "not worse per seed" and this is not that. Everything else — the other ten seed/scene combinations by track, all twelve by solve, and the real-track count in every one — is equal or better.

The bench scenes are less contaminated than the live fleet. The audit measured 58–65% of dark candidates carrying an unseeing node; the ring scene's baseline reads 32% and the scatter scene's 20%. Neither simulator scene reproduces the real fleet's density of unmodelled traffic, so the live test on the droplet is the measurement that decides this.

Verification

  • libs/retina-analytics: 425 passed.
  • Backend full suite (-n 2 -m "not external"): 2775 passed, 2 skipped.
  • pre-commit run --all-files: clean.
  • scripts.generate_openapi: no diff.

🤖 Generated with Claude Code

claude and others added 8 commits September 5, 2026 06:25
Bumps libs/retina-analytics to the branch commit that stops register_node and
rebuild_zones_for building an overlap zone between two nodes in different
worlds (offworldlabs/retina-analytics#25).

This fleet is the reason: 50 synthetic nodes and 8 receivers share one
footprint over Greenville, so the associator held a zone for all 400 sim/real
pairs — 39 of them a live adjacency edge — and a pairing drawn from one of
those grids can only ever match a simulated tracklet against a real echo.
Real node ids showed up in 48 of 156 synthetic dark solver records over a
7-minute window.  node_world is already the single authority for the question
and is already injected as node_world_provider, so nothing new is wired here.

/api/radar/association/status gains assoc_world_skipped_pairs next to
overlap_zones: without it a fleet whose cross-world pairs are being refused
reads exactly like a fleet whose pairs never overlapped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The ghost rate cannot see the failure the cluster-merge rework targets.  A
solver input that merges two aircraft usually still solves within MATCH_KM
of one of them, so it counts as *matched* while carrying 4-5 km of position
error and a node that never saw the aircraft it was published as.  Nothing
in the bench reported that, so the fix had no acceptance criterion.

--mode track now scores every solver input association emits, ahead of the
solve and ahead of every gate below it, against a truth side-channel: which
aircraft actually produced each detection.  That channel cannot come from
the frame's own ADS-B list -- the simulator appends None there for every
aircraft with has_adsb False, and dark aircraft are exactly the population
under study -- so each detection is matched back to the aircraft whose
noiseless (delay, doppler) it is nearest, at a gate ~5 sigma wider than the
simulator's own measurement noise.  It is built before _strip_adsb and never
reaches association, so the blind discipline is intact.

Reports contaminated_inputs_pct and foreign_nodes_per_input, per seed and
pooled, alongside the ghost rate and matched error already there.

Also adds the cluster-merge knobs (--merge-dist-km, --pair-vel-exclusive,
--merge-vel-consistent and the two velocity thresholds) so each sub-step of
the rework can be swept on its own; each defaults to None, meaning "leave
the library's default alone", so a plain run measures what the library
currently ships rather than freezing today's values into the bench.

Pins retina-analytics at fix/cluster-contamination.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two independent changes to the same enqueue-and-solve path.

1. SOLVER_ALT_MODE (core/state.py, default "sweep" — nothing changes until
   it is set).  The n>=3 altitude sweep solves once per fixed layer and keeps
   the lowest rms_delay.  Its layers are 2 km apart, so the altitude it pins
   is systematically up to 1 km wrong, and on noise-free replay of this
   fleet's geometry that quantisation ALONE left rms_delay at a 1.76 us
   median against the 3.0 us reject gate — most of the gate's budget spent on
   an altitude the measurements themselves determine, and the residual left
   over then blamed on nodes, so _trim_and_resolve drops measurements that
   were never the problem.

   In "free" mode _solve_best_altitude makes ONE pool call to the
   geolocator's solve_multinode_multistart with three start layers (the one
   nearest the association guess and its two neighbours), altitude solved as
   a sixth unknown — against the sweep's six round trips, each of which
   pickles the node configs for a child.  n=2 is untouched: altitude is
   unobservable there, so _solve_best_altitude_n2 keeps its single pinned
   solve and the geolocator pins regardless of the flag.  Trimming re-solves
   through _solve_best_altitude, so a trim round inherits the mode its first
   solve used — mixing a swept altitude with a free one would make the rms it
   compares between rounds a different quantity.

   Both modes stamp altitude_mode on every history record, published or
   rejected, and free adds the per-start residuals and z_saturated.  That is
   the whole comparison channel: one deploy of each, read off
   /api/test/mlat-history.  There is deliberately no shadow mode — the two
   produce the same shape of result, so running both would double the
   solver's cost to learn what a deploy of each already says.

2. The frame path shipped ALL 58 node configs with every solver input.  The
   pool is a spawn pool, so that whole set is pickled per solve while a
   candidate carries 2-8 measurements.  configs_for_solver_input restricts it
   to the measurement node ids; nothing downstream needs the rest — the
   solver builds NodeSetups from the measurements, trimming and consensus
   only ever narrow that set, the beam gate iterates contributing_node_ids,
   and cv_epochs is built from the same matched nodes in all three input
   shapes association emits.  The known lane fetches its own configs and is
   unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ruff format wrapped the truth-channel lookup and SIM910 wanted the
redundant None default off dict.get.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three of the signals the dark-lane recovery work is judged on could not be
read from a running server, so every judgement about it has been offline
replay.  This adds no behaviour: nothing about which candidates solve, which
solves publish, or what reaches the map changes.

1. /api/test/mlat-history caps per lane.  The known lane writes ~16x the dark
   lane's volume, so the flat records[:1000] answered a 30 min request with
   the newest ~6 min of dark records and the rest of the window read as a
   quiet period — the same failure the shared deque had before #289 split it,
   moved into the reader.  ?lane= narrows to one lane, ?limit= (max 5 000) is
   applied per lane, and lane_counts is reported pre-cap so truncation stays
   legible.

2. Resolve-slot skips are recorded, not just counted.  Live the rule refuses
   ~1 537 candidates per 646 dark attempts per 30 min, and nothing said WHICH
   claim did it — so a skip that suppressed a duplicate and one that
   suppressed a different aircraft (tracker track ids are shared across
   candidates) were indistinguishable.  A separate 500-entry deque carries
   the blocking claims; solver-stats windows it as resolve_skips, and
   ?kind=resolve_skips dumps it.  Its own deque and not the solve history:
   skips outrun dark records two to one and would evict them.

3. Contamination is measured live.  A dark record matched to ground truth now
   says which of its own contributing nodes could not see that aircraft
   (foreign_node_ids/contaminated), using the associator's own visibility
   predicate — the same gate known-lane claiming uses, so the two cannot
   drift apart.  The GT trail lookup already happened; this costs one cone
   test per node.  Records nothing could be asked about stay out of the
   denominator rather than counting as clean.

4. NODE_FRAME_MIN_INTERVAL_S drops are counted.  frames_dropped is published
   but is the queue-saturation counter and reads zero throughout; the frames
   the per-node rate limiter refuses were uncounted entirely, so "the tracker
   sees what this node sent" looked true from every metric.

docs/solverflow.md's file:line references are replaced with file + symbol —
every one of them had drifted — and gain a section on reading these endpoints.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The suppression rule reads "this aircraft is already on the map at this
width, at every track it is built from". _claim_resolve_slot recorded the
claim BEFORE the solve, so a candidate that never reached the map still made
that statement — and never released it.

Two consequences, both live:

* A rejected candidate blacked out its own identical twin for the full 12 s.
  24% of dark attempts are rejected, and the retry that would have published
  was suppressed by the failure.
* Tracker track ids are shared across the association candidates of DIFFERENT
  aircraft (74 of 178 ids in a 6 min window appeared in solves of more than
  one ground-truth aircraft — the finding that forced _supersession_match's
  spatial guard in #290). So a contaminated superset that the gates sank also
  suppressed the clean subsets behind it, including its neighbour's only
  candidate.

Measured on the test droplet: ~1,537 skips against 646 dark attempts per
30 min. The mechanism refused more than twice as many candidates as it
solved, for aircraft it had put nowhere.

_claim_resolve_slot splits into a pure _resolve_slot_covered (read-only,
run before the solve, also returning the blocking claims for the skip record)
and _record_resolve_slot, called only on the publish path with the POST-TRIM
survivors — result["source_track_ids"], which _filter_s_in_to_nodes rebuilds
from the surviving track_ids_by_node. A trimmed node's track is deliberately
left unclaimed: it contributed nothing to the published position and was
probably another aircraft's, so claiming it would suppress that aircraft on
the strength of a measurement this solve threw away.

The rule itself is unchanged: every track covered at no fewer nodes within
_SOLVER_RESOLVE_INTERVAL_S, widest claim wins, same pruning sweep. No
negative claim for rejects — measure first.

The cost is that the check no longer claims under the same lock, so two
workers can both solve duplicates that arrived together. That is one extra
solve, arbitrated downstream by keying and supersession, against the
starvation above.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The candidate-level rate has a denominator association itself moves.
Splitting one contaminated cluster into two clean sub-clusters plus the
false pairing it was hiding emits three inputs where there was one, and the
false pairing on its own scores as 100% contaminated -- so the rate rises
while what reaches the map gets cleaner.  Measured on the 50-node scatter
scene: 20.3% -> 25.7% of candidates, with the ghost rate by solve falling
3.2% -> 2.4% over the same change.

So the same score is now also taken at the publish point, over solves that
cleared every gate and bound to a real aircraft.  That is the population the
live audit sampled (45% of published dark solves carried a foreign node),
and its denominator is fixed by the aircraft in the sky rather than by how
many hypotheses association chose to emit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
/api/radar/association/status filed track_pairs_superseded under
"track_pairs_inline_only", documented as permanently zero in production
because the only exclusivity stage ran on a chi2 that cv_fit=None never
computes.  The deferred path now has an exclusivity stage of its own, so the
counter moves on a live fleet and that grouping would be a lie.  Moved to
the live block alongside the new cluster_splits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

This comment has been minimized.

Picks up offworldlabs/retina-analytics@7e5414a, which extends the cluster
partition from same-node conflicts only to every group, bounding each
emitted solver input at the merge distance.  Foreign nodes per published
solve 0.80 -> 0.32 on the 15-node ring bench scene.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

This comment has been minimized.

jehanazad and others added 2 commits September 5, 2026 08:36
…r more

The free mode shipped with three start altitudes on the reasoning that
freeing z removes the ladder's quantisation but not the LM's locality, so
several starts are still what keeps a solve off the wrong side of a
bistatic ellipse.  On this fleet's geometry there was almost nothing for
them to keep it off.  Over a 20-minute window on test, 1019 free-mode
solves: the three starts' rms_delay differed by more than 0.1 us in 13 of
them (1.3%), and the nearest-layer start — the one the sweep would have
pinned at — was more than 0.5 us worse than the best start in 2.  So the
extra two starts bought ~0.2% of solves a rounding error at three times
the solver CPU, and solver CPU is now the constraint: ~1.7 attempts/s
against a 2.0 s average latency on two pool workers.

The count becomes SOLVER_FREE_ALT_STARTS, default 1, read per call beside
the mode flag and clamped into [1, len(layers)] by _free_alt_starts
against the ladder that module owns.  One start is the layer nearest the
association guess, or the guess altitude itself when the input carries a
non-layer one (ADS-B) — the same splice the sweep does, so the one exact
altitude a candidate has is still what it starts from.  Above 1 the
window is unchanged, so 3 reproduces what was measured; the knob stays
because locality is a property of the geometry, not of this fleet, and a
deployment whose nodes sit nearer an ellipse should not need a code
change to buy the starts back.

alt_starts_km / alt_start_rms_us keep being recorded — a one-element list
rather than three — so the live comparison channel is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Picks up offworldlabs/retina-analytics@2b83871.  The 6.0 / 4.5 / 3.0 km
sweep on the association bench is monotone with the real-track count flat
at every point, so 3.0 is taken: published contamination 32.0% -> 25.1%,
foreign nodes per published solve 0.76 -> 0.52.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

This comment has been minimized.

Picks up offworldlabs/retina-analytics@67f1488 (ruff-format only, no
behaviour change).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

This comment has been minimized.

…r-observability

docs/solverflow.md: keep this branch's symbol-named 'Defined in' column and
add #292's SOLVER_ALT_MODE / SOLVER_FREE_ALT_STARTS rows and section in it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…is merged there

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ix/cluster-contamination

test_analytics_routes.py: keep both new status tests (live counters from this
branch, world-skipped pairs from #291). libs/retina-analytics: point at
analytics main (#25 world gate + #26 cluster split + #27 epochs merged).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jehanazad pushed a commit that referenced this pull request Sep 6, 2026
…ts-and-dr-budget

.env.example: both new comment blocks, the shared commented knobs once.
docs/solverflow.md: symbol-named constants table from the stack plus this
branch's MN_DR_CAP_S / MN_DARK_EXPIRY_S rows; section 7 kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Claude finished @jehanazad's task in 5m 13s —— View job


Reviewing PR #295 (update)

  • Gather context (PR body, prior review comments, changed files)
  • Diff origin/main...HEAD (via gh pr diff, since the local shallow clone had no merge-base) and separate what's new since the last review from what came in via the stacked-branch merges (feat/assoc-world-gate-overlap, feat/free-altitude-solve, feat/dark-solver-observability, fix/resolve-slot-claim-on-publish)
  • Re-check the three previously-flagged association_bench.py findings and the "still open" cluster_splits doc item
  • Review the newly-merged production code (solver.py, state.py, frame_processor.py, tcp_handler.py, routes/test.py) for correctness
  • Post updated review feedback

Note: I could not run the test suite or pre-commit in this environment (no .venv, and package installs would need network access this session doesn't have). This review is static/manual only — the PR description's own verification numbers (2775 passed, 2 skipped; pre-commit clean) are the test-side evidence.

What changed since the last review

The cluster_splits doc/label finding from the last pass is fixed: backend/routes/analytics.py:144-146 now describes it as "position clusters that held two tracks of one node and were emitted as one solver input each," no longer the stale "same-node track conflict only" wording.

The three backend/scripts/association_bench.py findings from the last review are still open, unchanged:

  • any(contam_rates) at line 1971 still hides a genuine all-zero-contamination result.
  • The _score_contamination docstring at lines 330-333 still claims the tie-break criterion matches the ghost/matched split, when the latter measures distance from the solved output, not initial_guess.
  • The exact-tie edge case at line 315 (best2[0] < 4.0 * best[0]) is unchanged.

All three remain low-severity/bench-only as previously assessed — nothing here blocks merge.

New content: the stacked-branch merges

Most of this update's diff (solver.py +300/-28, state.py +81/-8, frame_processor.py, tcp_handler.py, most of the new/changed test files) comes from merging free-altitude-solve, dark-solver-observability, resolve-slot-claim-on-publish, and assoc-world-gate-overlap into this branch — not new work written for cluster-contamination itself. I read through the merged logic for correctness:

  • _resolve_slot_covered / _record_resolve_slot (solver.py): the admission-time claim was moved to the publish path, using post-trim source_track_ids. The control flow reads correctly — _resolve_slot_covered is now pure (no side effect), and the claim is written only from the publish branch, outside _MN_TRACKS_LOCK as documented to avoid nested locking.
  • _stamp_foreign_nodes (solver.py:1239+), the actual live contamination measurement this PR exists to ship: it stamps foreign_node_ids/contaminated from _point_in_beam against the matched ground-truth position, over both contributing_node_ids and trimmed_node_ids, and correctly leaves a record unstamped (not "clean") when no node has registered geometry. This lines up with how routes/test.py's new contamination block reads it (judged = [r for r in records if r.get("foreign_node_ids") is not None]).
  • _free_alt_starts / _solve_best_altitude (solver.py): mode is read per-call (not captured at import), and _trim_and_resolve threads multistart_fn through so a trim re-solves under the same altitude mode — as the comments require, since mixing swept and free residuals across trim rounds would make the comparison meaningless.
  • configs_for_solver_input (frame_processor.py): narrows the per-candidate node configs shipped across the spawn-pool boundary; nothing downstream (beam gate, consensus, cv_epochs) needs the excluded configs, matching what the docstring claims.
  • Minor, pre-existing pattern (not introduced fresh, just reused): _record_resolve_skip's guess_lat/guess_lon use if ig.get("lat") else None, which would misreport None for an exact 0.0 coordinate — but this is the same ternary already used at solver.py:1751-1752 for the solve-history record, and RETINA's node fleet has no node near the equator/prime meridian, so it's not a live concern. Not flagging as an actionable finding.

No new correctness bugs found in the incremental content. The core contamination fix (library pin + bench measurement + live _stamp_foreign_nodes/contamination reporting) is internally consistent, and the acceptance-target miss is already transparently documented in the PR body itself.

@jehanazad
jehanazad marked this pull request as ready for review September 6, 2026 02:38
@jehanazad
jehanazad merged commit c94d1d4 into main Sep 6, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants