Skip to content

LiDAR state estimation foundations - #559

Open
ddol wants to merge 152 commits into
mainfrom
dd/docs/state-est
Open

ddol wants to merge 152 commits into
mainfrom
dd/docs/state-est

Conversation

@ddol

@ddol ddol commented Aug 26, 2026

Copy link
Copy Markdown
Member

Purpose

LiDAR tracking produces jittery, split, and duplicate-identity boxes on real captures. This PR
is the measurement and reference-data foundation the
state estimation plan calls for, plus the fixes
that could be validated without it. It is large — two agents worked this branch in parallel —
and this description reflects the state as merged, not the sequence it was built in.

Changes

Shipped, on by default

  • Heading lock ratchet broken (D1.3): a counter now forces a stuck OBB heading to release
    and snap to measurement after repeated rejections, instead of holding forever.
  • Fragment guard (D1.5) and ghost-fade decoupling (D1.6): a sub-metre scrap can no
    longer overwrite a track's dimensions or seed a duplicate track on the same vehicle; a
    deleted track's frozen box now fades in ~500 ms instead of 5 s.
  • Deterministic DBSCAN subsampling: the over-cap point subsample used to seed from
    time.Now(); it now seeds from the point content, so replaying the same capture reproduces
    the same clusters. This is load-bearing for every measurement below — nothing is comparable
    run-to-run without it.
  • Course-alignment and heading-lock telemetry (D1.1, D1.2): |OBB heading - direction of travel|, folded to [0, 90], plus per-track lock-episode outcomes (none /
    never_recovered / relocked / released), replacing a lifetime-only flag that couldn't
    distinguish a genuine trap from a track that already recovered.

Experimental, off by default

  • Axis-selection heading test (D2.1, obb_axis_coherence_enabled): chooses between the
    aligned and quarter-turn-swapped interpretation of an observed rectangle against a
    corroborated-maximum extent belief — the largest span several confidently-assigned
    observations have reached, not a running mean (a mean shrinks the object: most views are
    partial). Measured on two captures: median course error improves (42.3->37.3 deg, 36.3->10.7
    deg) but heading acceptance drops ~23 points on both, so it stays off pending D2.2.
  • Bounded association shape cost (D2.2, association_extent_cost_weight): converts the
    hard fragment guard into a finite penalty so a genuine partial view isn't forced into seeding
    a duplicate track. Reduces duplicate-identity candidates (overlapping boxes) on both test
    captures, 9% and 44%, but course error moves in opposite directions between them at sample
    sizes too small to trust — stays at weight 0 pending a physical-object reference indepenent
    of predicted track IDs.

Reference-data foundation

  • internal/lidar/annotation/: immutable, content-addressed point packs cut from a VRLOG
    (digest-verified, atomic write, refuses to overwrite), plus a revisable sidecar of
    human-reviewed object/mask/pose records. Exists because a tracker's own track IDs cannot be
    the reference for judging that tracker — they split and merge between runs. velocity lidar annotation-export cuts a pack; round-trip, tamper-detection, and proposal/reviewed
    separation are under test.
  • internal/lidar/l4bobserve/: early observation-model types (Phase 1 of the state
    estimation plan) — the first step toward persisting what the sensor actually saw, separately
    from the filter's estimate of it.

Phase 0 measurement instrumentation

  • Filter consistency baseline: per-speed-band lateral/longitudinal residuals and NIS
    (normalised innovation squared), computed where the Kalman gain already has the innovation
    and its inverse covariance. Published as tracking_baseline.json beside every replay.
    Measured on two site windows: the fixed scalar measurement noise is underconfident at low
    speed (NIS ~0.2-0.8) and overconfident above 5 m/s (NIS up to 20, small samples) — evidence
    for Phase 3's observation-conditioned uncertainty, not just an assumption.
  • Debug collector wired: innovations, gating ellipses, and predictions were implemented
    and never enabled; now reachable.

Performance matrix

  • Host class as a first-class benchmark dimension (pi / mac / ci): the baseline
    namespace previously had one slot per capture+profile, so a Pi and a Mac collided on the
    same file and the Pi could not be gated without destroying the Mac baseline. Comparisons
    across host classes are now refused rather than silently reported as a regression, with
    per-class thresholds (20%/30%/50%) and repeat counts reflecting each class's actual noise
    floor.
  • Pi benchmark runbook: the manual
    procedure for capturing the one matrix cell that answers "fast enough for the sensor" —
    nothing runs it yet, tracked as a backlog item.

Reference client and operator view

  • Point-cloud annotation client (tools/visualiser-macos/.../Annotation/): the selection UI
    the annotation packs were always missing. Orthographic lasso and rectangle selection over a
    depth slab against canonical pack indices (never GPU buffer positions), add/subtract,
    stroke-level undo/redo, a second view for the contamination check, operator provenance, and
    dirty-navigation protection. The sidecar writer follows the Go store's revision protocol, so a
    save that cannot prove it edits the snapshot it loaded is refused rather than overwriting
    another writer. 94 Swift tests, with the pack reader tested against bytes the Go writer
    actually produced and pinned on both sides. Selection is through-slab, not hidden-surface
    picking; a radius brush and reviewed propagation remain future work.
  • Operator tracks page on the shared scene player: /app/lidar/tracks now renders through
    the same three.js player as the published scenes, driven by a session built over live database
    observations, so seeking, playback and trail reconstruction are one implementation rather than
    two. Track picking and missed-region marking were ported into the 3D view and the 932-line flat
    canvas map deleted, along with the dead background-grid fetch, per-track observation cache and
    overlay-offset controls it had kept alive.

Paper-implementation gap remediation

Three gaps from the gap analysis were
implemented and measured. In each case the measurement contradicted what the gap row
predicted
, which is recorded there alongside the numbers.

  • Kalman K1 and K2, both opt-in with the shipped defaults unchanged, because neither
    predicted failure mode reproduced. K1's coupled process noise behaves as the maths says but is
    2.7% of the normalised distance to a 2 m/s² manoeuvre against a gate 173x wider, so association
    here is governed by gate width and not the form of Q. K2's premise did not reproduce at all:
    the shipped naive covariance form lost exactly zero symmetry over 10,000 cycles with
    adversarial noise. They are Go-level options rather than tuning keys on purpose — a new tuning
    key moves the config fingerprint and would invalidate the committed perf baselines before
    anyone had measured whether the change helps.
  • OBB numerics P1 and P2: the estimator handles every degenerate input correctly, but L5's
    Guard 2 — which exists to refuse an ambiguous heading from a near-square cluster — guarded its
    division with maxDim > 0 and so skipped the check entirely for a zero-extent box, accepting
    the most ambiguous measurement while correctly refusing a 5 cm square. P2's wrong-answer
    fallback branch is removed rather than corrected, by computing the discriminant as the
    non-cancelling (c00-c11)² + 4c01².
  • HW1 sensor noise, measured with a new lidar-closeness-audit against 367 deployed
    background snapshots and 24,007,669 settled cells: the sensor beats its own specification in
    situ (median spread 6 mm close in, 47 mm past 100 m), while the modelled noise term supplies
    86-98% of the acceptance window against the measured spread's 1.5-5%. The median effective
    window reaches 1.11 m at 10-20 m and 8.99 m past 100 m. A follow-up ring-elevation pass
    disconfirmed the obvious explanation for the tail: the rings reporting the most foreground
    carry the fewest wide-spread cells, inverting the feedback-loop prediction. The detection
    cost is not measured
    and is filed as a backlog item.

Phase 2 measurement model

  • Synthetic Pandar40P pass promoted into l4perception as the seed of the evaluation corpus,
    so Section 3's evidence is reproducible in the repository rather than living in a scratchpad.
    It records one correction to the plan: §3.1's stated ring band cannot see its own vehicle for
    most of the pass, so the generator fires the real hardware elevation table and reproduces the
    mechanism — the medoid pinned at exactly -0.900 m, W/2, on 20 of 40 frames — rather than
    §3.2's per-frame figures.
  • Near-edge measurement model with Section 9.1's variable-rank measurement, which is the
    property the plan identifies as missing: one visible face constrains its own normal and leaves
    the perpendicular direction to the prediction untouched, with a covariance tight along the
    constraint and effectively unbounded across it. Lateral error against the synthetic pass is
    0.0000 m mean and max, against the OBB centre's 0.1391 m and 0.8038 m. Its bias is exactly the
    dimension-prior error one-for-one, which is what makes the solid body's per-dimension sigma and
    provenance load-bearing rather than decorative.
  • Solid-body estimate contract (Section 5.4): named physical reference point, the filter's
    own covariance block, a bimodal orientation belief carrying the 180-degree ambiguity explicitly
    rather than resolving it with a guard, per-dimension beliefs with sigma and admissible-frame
    counts, motion class as a posterior, and the estimation lifecycle. ProjectSurface returns
    unavailable — for suppression, never substitution — whenever pose, heading and dimensions
    cannot be bounded together. Seeding states the medoid's known bias, ~0.95 m for a vehicle,
    where the measurement noise would have claimed ~0.22 m about a position that may be a metre
    out. Not yet wired into the live pipeline.

Experiment E1: the question this branch existed to answer

Run against all three corpus sites: 43,068 scored frames matching the committed Phase 0/1
baseline exactly, 195,389 immutable observations, 160,011 linked estimates, every site
repeat-verified byte-identical.

E1.1, the decisive conditional-mean-by-aspect test, confirms Section 3's hypothesis. The
medoid's lateral offset is near zero end-on and rises monotonically toward broadside, to 0.35-0.41
of the body's half-width, at all three placements; the near-edge candidate stays flat and of
opposite sign. E1.3 agrees independently at 0.58-0.65 of a half-width, needing no fit and no truth.
The trend survives range stratification in every well-populated cell, which is the
discriminator that separates a geometric bias from the P11 grade confound — and it is as strong at
the flattest placement (0.14% grade) as the steepest (4.35%).

So the defect is in the measurement definition and no estimator can filter it out, which is what
Phase 2 was predicated on. A sign bug in the first E1.3 implementation initially inverted its
conclusion by letting vehicles passing on opposite sides cancel; E1.1's independent reference
exposed it, and that is recorded rather than quietly fixed. Full record, including three limits
stated rather than implied — no ground truth in this corpus, the OBB centre's circularity, and an
empty ground-clipped stratum:
E1 lateral-error record.

Known gaps (tracked, not blocking on their own)

  • mac/ci perf baselines are stale Resolved. The mac baselines are recaptured and
    make test-perf-all passes both gated profiles. Diagnosed before recapturing, since recapturing
    a refused baseline on sight risks enshrining a regression: the committed file recorded a workload
    the branch does not produce (1,611,256 foreground points against an actual 985,224), and the
    branch's actual workload matches main's baseline to within one cluster, so nothing had
    regressed and the stale file had captured a transient intermediate state. No pi baseline
    exists yet; that is the runbook item below.
  • D2.1/D2.2 cannot be finished without a physical-object annotation independent of predicted
    track IDs. The selection UI that blocked this now exists (see the annotation client above), so
    what remains is operator hours rather than engineering: reviewed masks across a site's
    keyframes, then the frozen object-disjoint dataset splits.
  • E1.2 (straight-segment residual spectrum with autocorrelation) and E1.4 (stationary noise
    floor, the one place real data supplies an exact expected value) are not yet run; G-GEO-1 needs
    both plus held-out geometry, excursion and fragmentation criteria.
  • No dependency-free lidar-bench binary for the Pi (native build only); no scheduled Pi run.
  • Two config-schema fixture gaps (missing required keys in config-validate/config-migrate
    test fixtures after schema additions) were found and fixed in this branch's own history —
    flagging the pattern: a strict schema addition needs every inline test fixture updated in
    the same commit, and make test-go alone did not always catch it (stale test cache).

Checklist

  • Uses British English spelling/wording where applicable.
  • Design is confirmed and aligns with DESIGN.md.
  • Any maths/derived values are valid and up to date.
  • Version has been bumped where required.
  • Documentation is up to date. (plans, backlog and gap analysis current; perf baselines
    recaptured; E1 recorded)
  • README.md is up to date.
  • docs/DEVLOG.md is up to date.

🤖 Generated with Claude Code

@ddol ddol self-assigned this Aug 26, 2026
Copilot AI lite review requested due to automatic review settings August 26, 2026 18:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review any files in this pull request.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@ddol
ddol force-pushed the dd/docs/state-est branch from 9af7734 to 6d8c799 Compare September 3, 2026 08:05
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@ddol ddol changed the title [docs] plan: LiDAR state estimation, vehicle behaviour [go] LiDAR state estimation Sep 6, 2026
@ddol
ddol requested a balanced review from Copilot September 6, 2026 03:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It makes safety-relevant state-estimation/tracker behaviour changes across many packages while being labelled documentation-only, so it needs human verification of correctness, tuning safety, and the mislabeled scope.

Review details
  • Files reviewed: 52/55 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/lidar/server/tuning_runtime.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The change is large and cross-component (config schema, tracking, clustering, sweep objective, perf harness), the shown diff is only a subset of the actual runtime changes, and the description understates the scope, so it needs human review.

Review details
  • Files reviewed: 122/127 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/lidar/sweep/sampler.go
@ddol ddol changed the title [go] LiDAR state estimation LiDAR state estimation foundations Sep 8, 2026
@ddol
ddol force-pushed the dd/docs/state-est branch from 913f7aa to cfe54be Compare September 12, 2026 03:26
@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown

🤖 Version Bump Advisory

Warnings

⚠️ Database migration detected

  • Files: internal/db/migrations/000047_lidar_observations.up.sql,internal/db/migrations/000048_lidar_track_estimates.up.sql,internal/db/migrations/000049_lidar_track_residuals.up.sql
  • Review for breaking changes (MAJOR) vs additive changes (MINOR)

Version Updates

✅ Radar: 0.5.1-pre33 → 0.5.1-pre34


📖 See CHANGELOG.md for detailed guidelines.

This is an automated advisory. Review the detected changes and update versions accordingly.

@ddol
ddol force-pushed the dd/docs/state-est branch from 4c98066 to 6af3784 Compare September 12, 2026 06:36
ddol and others added 13 commits September 13, 2026 02:55
Investigation into the reported defect where a tracked vehicle's bounding
box steps roughly one metre laterally for a few frames and returns.

The defect was reproduced using production l4perception.DBSCAN and
EstimateOBBFromCluster against a synthetic Pandar40P with zero measurement
noise, a rigid box vehicle, and a straight path at constant speed: maximum
frame-to-frame excursion 1.119 m, mean lateral bias 0.676 m.

Root cause is the measurement definition, not the filter.
computeClusterMetrics sets WorldCluster.CentroidX/Y to the medoid: the real
LiDAR return nearest the arithmetic mean. A medoid can only sit on a visible
face, so it carries a viewpoint-dependent bias of up to W/2 and hops between
faces as visibility changes. Because that error is a smooth function of
viewing geometry and stays correlated over tens of frames, it violates the
zero-mean white-noise assumption, and CV, CA, CTRA, UKF and IMM all inherit
it unchanged. The first increment must therefore be an observation model,
which reorders the sequencing recommended in pipeline-review-open-questions
Q5.

Measured comparison of candidate measurements over the same 40-frame pass:

  medoid centroid (today)        0.676 m mean bias / 0.680 m max hop
  OBB centre                     0.279 m / 0.565 m
  nearest OBB corner             0.202 m / 1.398 m (corner identity flips)
  corner plus temporal identity  0.337 m / 0.675 m
  near edge plus dimension prior 0.035 m / 0.370 m

Further findings measured against sensor_data.db (55,315 tracks and
3,526,860 observations):

- moving tracks are associated on only 43.6 % of sensor frames, so the
  effective observation rate is about 5 Hz rather than the sensor's 10 Hz
- 11.3 % of moving tracks show a lateral excursion above 0.5 m in the
  already filtered output
- lidar_clusters holds 0 rows and InsertCluster has no caller, so raw
  observations are never persisted
- lidar_track_observations stores the Kalman estimate, not the observation
- six quality columns in lidar_tracks are never written by InsertTrack
- the visualiser drops the observation for every tracked object, and the
  debug collector that captures innovations is implemented but never wired

The plan covers current architecture with source references, the five
requested comparison matrices with per-criterion scoring and decision gates,
proposed Go types and interfaces, observation uncertainty and residual
designs, persistence schema with retention policy, evaluation corpus and
partitioning, diagnostic harness requirements, and a nine-phase roadmap with
per-phase files, tests, migrations, costs, risks and acceptance criteria.

Documentation only. No runtime code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…iment

Three changes, in response to review.

1. Split the plan.

The single document held two workstreams with a strict one-way dependency:
producing a trustworthy trajectory (L4b/L5) and measuring road-user behaviour
from it (L7/L8). They have different decision types, different gates and
different audiences, and combining them pushed the document past 3,000 lines.

  lidar-state-estimation-plan.md    (renamed) owns Phases 0-5 and 8
  lidar-behaviour-analytics-plan.md (new)     owns Phases 6 and 7

Phase numbering is shared across both so cross-references survive. The
estimation plan keeps the jerk-observability and empirical-path conclusions,
because both are grounded in sampling-rate evidence that belongs with the
estimator, and gains a Section 13 stating the contract behaviour analytics
depends on.

2. Behaviour analytics specification.

Incorporates the attached specification with literature grounding. Structure:
five-way distinction between observable, surrogate, legal, population-relative
and longitudinal statements; benchmark taxonomy; opportunity normalisation; a
thirteen-group feature matrix with definitions, units, scope, map dependency,
roadside suitability and benchmark kind; equations for THW, TTC, PET and DRAC;
uncertainty propagation with derived suppression rules; L8 data model; dataset
strategy; phased roadmap 6A/6B/6C/7.

Measured constraint that shapes it: a typical vehicle passage is 9.6 s median,
42 observations, 51 m of observed path at 6-10 m/s, over 1,710 confirmed tracks.

Four conclusions that diverge from the brief's starting assumptions:

- SDLP cannot be measured here. It is the outcome of a ~1 hour standardised
  on-the-road test (Verster and Roth 2011). A ten-second passage is three orders
  of magnitude short. The roadside quantity gets a different name and must never
  be compared to SDLP norms.
- The two-second following rule is driver education, not a research threshold.
  The 100-Car study records headway but defines near-crashes by evasive
  manoeuvre, not a headway cutoff. THW bands are no_established_threshold.
- Point-count-style confidence is wrong for passing clearance: its sigma is
  dominated by extent, not position, so cyclist width must come from a class
  prior rather than a per-track estimate.
- PET needs no map. A conflict point can be derived from observed path
  intersections, which moves PET into Phase 6B.

Inspection finding: there is no posted speed limit anywhere in the schema. The
only one in the codebase is a per-request report parameter. Legal speed
benchmarks need it in site_config_periods, which already carries the
effective-date pattern.

DOIs are given only where directly observed; publisher URLs are used elsewhere
rather than constructing plausible-looking DOIs. The SSAM PET default of 5 s is
flagged as secondary, since the FHWA techbrief and validation report state the
TTC default and not the PET default.

3. Experiment E1 on the soma static captures.

Verified the four recordings: 38 m 01 s total, ~22,810 frames, 5,062 MB, all
Hesai Pandar40P on port 2369, same sensor, 2025-12-06 across four placements.
"static" means the sensor was stationary, not that the scene was empty.

They give viewpoint diversity and nothing else, which happens to be exactly the
axis the medoid-bias hypothesis depends on, and the axis kirk0 cannot probe.
There is no ground truth, so all four tests are designed to need none: the
decisive one bins signed lateral offset by aspect angle, where random error has
a zero conditional mean and a geometric bias does not.

Includes partition assignment across the plan's three partitions, the settling
budget constraint (soma2 is 69 s against a 60 s settling duration), and an
explicit statement that a negative result fires Section 15's first invalidating
condition rather than counting as experimental failure.

Documentation only. No runtime code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion

Architectural corrections from review. Preserves the investigation, matrices,
gates, experiments and measured findings; changes the architecture around them.

Cross-cutting: added an explicit principles section. No black boxes, and
estimate the most probable physical path rather than cosmetically smoothing.
Product language moves from "smoothed trajectory" to "final trajectory
estimate" and "retrospectively refined estimate"; algorithm names stay
technical. Both principles were being violated, which is why they are now
stated first.

State-estimation plan:

- Split Observation into immutable DetectionObservation and derived, versioned
  MeasurementInterpretation. The previous type mixed sensor evidence with
  fields requiring a track prediction, so two estimator versions could not
  consume the same evidence reproducibly. Recorded as defect P12.
- Resolved the state dimensionality contradiction. The draft proposed a
  six-element [x,y,psi,v,a,omega] state AND a linear filter; those are
  incompatible. Option A adopted: [x,y,vx,vy] with a 4x4 covariance, with
  orientation, dimensions and vertical position as separate beliefs. This keeps
  the filter honestly linear and isolates the observation-model change that
  G-GEO-1 exists to test. Gate G-EST-4 added for migrating to Option B, with
  low-speed conditioning as the deciding criterion.
- Added road-user motion models: rigid_vehicle, two_wheeler, pedestrian,
  unknown, with prior strength scaled by class posterior so classification
  uncertainty never becomes motion certainty.
- Added an estimation lifecycle distinct from the track lifecycle, with
  explicit initialisation rules. The position seed keeps the medoid but sizes
  its covariance to the known bias rather than the medoid's precision.
- Removed the medoid fallback during model degradation. It silently redefined
  X and Y mid-track from estimated physical pose to raw cluster point, which
  was the most dangerous line in the previous draft.
- Made abnormal-motion thresholds class-conditioned. A 90-degree heading change
  is a spin for a car and an ordinary turn for a pedestrian.
- Replaced the dimension sketch with a frame-admissibility taxonomy, a bounded
  class-anchored quantile estimator, and three defences against the
  merged-cluster ratchet.

Elevation, per the answer that deployment sites are graded:

- Added defect P11: ground removal is a flat height band on absolute
  sensor-frame Z and is documented as not slope-aware. On a grade it clips
  vehicles at one end of the scene and admits ground at the other, corrupting
  the cluster extents the near-edge measurement depends on. Remedy pulled into
  Phase 1.
- P11 also threatens Experiment E1: on a straight graded approach, range
  correlates with both filter error and aspect angle, so a ground artefact could
  fake or mask the aspect-conditioned bias E1 exists to detect. Added a
  three-step mitigation: measure grade first, record GroundClipped per
  observation, stratify E1.1 by range as well as aspect.
- Added the road-surface frame, the separation of road-user dynamics from
  road-surface geometry, discontinuous intersection surfaces, and a capability
  split. Substance stays in the L7 scene plan; this plan owns the interface and
  the honest planar fallback.
- Added class-general and grade-aware synthetic test coverage.

Behaviour plan:

- Development is unblocked from G-SMO-1; production emission is not. The gate
  now covers the genuinely dangerous case, computing metrics from today's
  biased tracks, while allowing work against fixture trajectory streams.
- Added a metric framework ahead of the feature matrix: three-tier road-user
  scope, declared per-metric applicability, a closed SuppressionReason
  vocabulary, an observation-support taxonomy separating occluded_inferred from
  missed_unknown, and interaction classification as a posterior that gates which
  formulae may run.
- Split results into BehaviourMeasurement and BehaviourOutcome so categorical
  outcomes keep references to the evidence they were derived from.
- Generalised passing clearance to minimum synchronised surface-to-surface
  separation, with lateral clearance as a projection for confident overtakes.
- Replaced scalar sigma with an Uncertainty struct supporting intervals, bounds
  and a propagation method, and carried three uncertainty scopes so pairwise
  common-mode cancellation is a later refinement rather than a migration.
- Marked jerk experimental with a published-boundary acceptance test.
- Separated engineering dependency order from product priority, with
  vehicle-cyclist passing clearance leading the product list. The two orderings
  are close to inverted at the top.

Both documents end with a "Changes introduced by this revision" section and
answer the questions the revision raised.

Documentation only. No runtime code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Decisions taken, recorded in state-estimation Section 21.1:

- D1: P11 (slope-unaware ground removal) is a current defect, not future work.
  Deployment sites are graded. Severity still to be confirmed by measuring the
  grade per capture.
- D2: ship the OBB centre as an immediate measurement stopgap. 0.279 m mean
  lateral bias against the medoid's 0.676 m, a 2.4x improvement for a change of
  measurement source. Fenced with four conditions: it is still a visible-surface
  artefact, validate via E1.3 first, persist which source produced each
  estimate, and re-baseline G-GEO-1's regression numbers afterwards. Without the
  source field the stopgap silently splits the historical record into two
  incomparable regimes, which is the same class of error as the medoid fallback
  removed from Section 12.
- D3: gate set confirmed as G-PER-1, G-GEO-1, G-UNC-1, G-EST-1, G-SMO-1.
- D4: product priority leads with vulnerable-road-user interactions.

Experiment E1 corpus corrected against the real capture set:

- soma2 dropped: at 69 s against a 60 s settling duration it cannot yield a
  meaningful partition.
- soma1 and soma3 re-split, now carrying a -0-1 segment suffix.
- clar0-1 added, and it matters more than the rest because it is a different
  site rather than a fourth placement at the same one. It becomes the held-out
  regression partition for that reason.
- Row values measured on the superseded splits are marked for re-measurement.
  /Volumes/lidar was not readable from this session, so only kirk0 was
  re-verified on disk.

Added the two known-defect VRLOGs (0fb02f22 from clar0-1, 60a4774c from kirk1)
as the labelling source for the regression set, with an explicit note that a
VRLOG replays decisions already made and so cannot run a candidate measurement.
They supply labelled cases; E1 runs from the source captures. The two were
produced by different builds under different tuning hashes and are not
comparable to each other.

Simplifications:

- Estimator matrix cut from six rows to three. CTRV, CTRA and a stationary mode
  all depend on evidence that does not exist, and carrying them implied a choice
  that is not open.
- G-EST-2/3/4 folded into a deferred-conditions table. Writing thresholds for
  them now would be guessing.
- Behaviour Section 8 gains a scope table: eight groups in, five deferred. Note
  this resolves toward the confirmed VRU-first product priority rather than the
  literal groups 1-3 plus 9 originally proposed, because that set would have
  deferred PET and yielding, which are product priorities 2 and 3.
- Shared production-database figures now cite state-estimation Section 1.5 as
  canonical instead of being restated in both plans.

Backlog: nine entries added, five under v0.5.5 tracker correctness and four
under v1.0, including the missing posted speed limit in site config.

Documentation only. No runtime code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…105d8)

Run f84105d8-b3be-416f-8809-551ef6bfce10 supplies real-data confirmation of the
mechanism that Section 3 previously demonstrated only synthetically, through a
second symptom the synthetic model predicts but that had not been checked.

6,846 frames, 11 m 03 s, 2,038 tracks, replayed from soma1-static-0.pcap at
playback rate 0.1. The slow playback is a control: the frame-rate throttle was
not engaged, so nothing below is a throughput artefact.

Estimated width collapses on moving tracks. Of 288 tracks with max speed at or
above 3 m/s, 172 (60 %) carry an estimated width below 0.5 m, narrower than a
pedestrian, and 210 (73 %) are below 1.0 m, narrower than any car. The mode is
0.1 to 0.3 m, which is the thickness of a single observed face rather than a
plausible object width.

That is the same cause as the position bias. With only the near face visible,
the medoid sits at W/2 from the true centre, and the extent across the object
collapses to the face's own thickness. One mechanism, two symptoms, and the
second is now measured on real traffic.

Corroborating, same run: 1,648 of 2,038 tracks (81 %) carry no class; the 15
classified car have a mean width of 0.85 m against a real 1.8 m; mean
observations per moving track is 6. The track in the inspector screenshots,
trk_4a27c73c, is classified car at 8.0 m/s with L x W x H of 0.8 x 0.2 x 0.2 m
and Hits 0 while Confirmed.

Consequences recorded:

- P7 restated. It is not that a running mean is biased low by partial views; the
  per-frame extents are themselves the wrong quantity under single-face
  visibility. Severity raised to High, since it starves the classifier and
  breaks every downstream clearance measurement. Section 9.2's admissibility
  rules are load-bearing rather than fastidious.
- The 81 % unclassified rate is arithmetic, not an independent defect. The
  classifier reads dimensions and speed; sub-metre widths starve it.
- Mean 6 observations per moving track is well below the 42-observation median
  in Section 1.5. The tuning hash differs from production, so it is recorded as
  run-specific rather than as a new baseline.

f84105d8 promoted to primary labelling source in 16.5, ahead of 0fb02f22 and
60a4774c. Two properties noted so they are not rediscovered: playback 0.1 as
above, and its source being the superseded soma1-static-0.pcap split rather than
the current soma1-static-0-1.pcap, so a re-run will not reproduce it frame for
frame. Cases are to be labelled by what they show, not by frame index.

Documentation only. No runtime code changes. All queries were read-only against
a WAL database and no live server was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…1.1)

Nothing in the pipeline measured whether a bounding box points where its
vehicle is going. AlignmentMeanRad compares Kalman velocity against
displacement: both describe motion, neither describes the box. HeadingJitterDeg
measures how much the box moves, so a box locked at the wrong angle scores
perfectly on it.

Add course alignment: |OBB heading - direction of travel|, folded to [0, 90].
An OBB is symmetric, so a box pointing backwards along the course is correctly
oriented and folds to 0; a 90 degree length/width swap is the worst case.
Sampled only on live frames at or above 2 m/s, where course is meaningful.
The tracker holds it as a 20-bin histogram so per-track cost is constant on
the Pi; the offline analysis path computes percentiles directly.

Measured on run baf20f02 (600 frames, 346 tracks), across the 55 tracks with
samples: median per-track course error 50.3 deg, p85 67.2 deg, worst 85.0 deg.
Track 18952226, one half of the split car in the report, sits at 66.4 deg while
its box moves 1.95 deg per frame. Jitter called that track healthy.

Two defects surfaced while wiring it up, fixed here:

- The offline and live HeadingJitterDeg measured different quantities. The
  analysis path used Track.HeadingRad, the Kalman course; the tracker used OBB
  heading deltas. These are the two paths an A/B comparison puts side by side.
  The box quantity is now reported separately as OBBHeadingJitterDeg, and both
  fields state which is which.
- Run-level aggregates key on final track state, and 211 of 346 tracks end
  DELETED, so the confirmed-only rollups covered 13 tracks. Course alignment
  rolls up over every track that produced samples instead. The existing jitter
  and alignment aggregates still have this flaw and are understated.

Also adds the two-day sprint plan this is the first task of, with the measured
evidence for the heading lock ratchet and the objective function that rewards
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Guard 3 in tracking_update.go rejects any OBB heading delta between 60 and 120
degrees, measured against the smoothed heading. Once that smoothed value has
itself drifted more than 60 degrees from the truth, every correct measurement
is rejected and the lock cannot release. Nothing recorded whether that had
happened to a track.

Add per-track HeadingLockedFrames, LongestLockRun, EnteredSustainedLock and
ReleasedAfterLock, with a derived LockTrapped for the case that matters: a
sustained lock that never released. A release needs five consecutive unlocked
frames, because Guard 3 rejects per frame and a single frame slipping through
between rejections is not the lock letting go. Both the live tracker and the
offline analysis path compute this, and a test asserts the two agree, since
they are the two sides of every A/B comparison.

Measured on run baf20f02, over the 239 tracks living at least five live frames:
83 tracks (35 per cent) enter a sustained lock and 54 of those (65 per cent)
never release. The longest single run is 152 frames, 15.2 seconds. Splitting
tracks by that outcome, trapped tracks sit at a median 60.1 degrees of course
error against 38.5 for the rest, so the lock costs about 22 degrees. That is
the measured case for the D1.3 ratchet fix.

Fixes a third defect found while wiring it up. The offline reconstruction
counted DELETED ghost frames, which carry heading source pca rather than the
lock the track died in, so fifty frames of ghost turned a track trapped for its
whole life into a clean one: trapped read 11 per cent of locked tracks instead
of 65. Lock stats now skip non-live frames. Excluding them dropped the pca
frame count from 15,069 to 4,459, a difference of 10,610, matching the 10,610
DELETED track-frames counted independently. Run-level ratios are also taken
only over tracks that lived long enough for a sustained lock to be detectable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (D1.3)

Guard 3 rejects any OBB heading delta between 60 and 120 degrees, measured
against the smoothed heading. That comparison is what makes it self-sustaining:
once the smoothed value has itself drifted more than 60 degrees from the truth,
every correct measurement lands inside the rejection band and is thrown away,
so the lock can never release. On run baf20f02, 65 per cent of locked tracks
never recovered, sitting a median 60 degrees from their direction of travel
against 38 for the rest.

Add a rejection counter. After OBBHeadingLockMaxRejections consecutive Guard 3
rejections the tracker stops believing its own smoothed heading and accepts the
measurement. Default 5; zero restores the previous behaviour.

Two details the fix depends on:

- The release snaps rather than eases. The EMA moves 8 per cent of the gap per
  update, so easing across a delta wide enough to be rejected would re-trigger
  the guard on the next frame and never converge.
- Only Guard 3 drives the counter. Guards 1 and 2 fire when the cluster is
  genuinely unusable, too few points or too near square, and snapping to such a
  measurement would replace a wrong answer with a random one.

Releasing also restores dimension updates, which are only written when the
heading update is accepted, so a released track stops carrying the frozen
length and width it locked with.

Proven on synthetic sequences reproducing the trap: a confirmed track whose
smoothed heading sits 90 degrees from its course, fed correct measurements,
stays above 80 degrees of course error with the release disabled and converges
below 5 with it armed. The two pre-existing Guard 3 tests still pass.

The end-to-end figure on baf20f02 is not measured here. A VRLOG replays
decisions already made and cannot re-run the estimator, so confirming the fix
needs a pipeline re-run over the source PCAP.

Config is strict on missing keys by design, so the new parameter is added to
the three shipped tuning files, to config-migrate (writing the shipped default
rather than the zero value, which would quietly migrate a config back onto the
ratchet), and to the runtime tuning endpoint's accepted keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
….5, D1.6)

D1.5, fragment guard. The association gate is a 6 m radius on position alone,
with nothing to stop a scrap of a cluster capturing a vehicle-sized track. On
run baf20f02 a 0.11 by 0.08 metre cluster was associated to a track carrying a
4.33 metre car and became its dimensions for the next 28 frames.

Forbid the pairing in the cost matrix rather than refusing it at update time,
so the fragment stays unassociated and can seed its own track instead of being
consumed. The guard fires only when a track has at least three observations,
believes it is at least two metres, and the cluster's longest extent is below
min_associable_extent_metres (default 0.5). It reads the running average
extent, not the latest frame, because the latest frame is exactly the value a
fragment would already have corrupted.

The guard is deliberately narrow. Pedestrian and cyclist tracks legitimately
carry sub-metre extents whose clusters vary by a similar amount frame to frame,
so a small-cluster rule applied there would reject ordinary observations.
Dimension consistency in the assignment cost proper is a separate change.

D1.6, ghost fade. Deleted tracks are published with a fade-out alpha, which is
a deliberate rendering feature. The defect was that its duration was
deleted_track_grace_period: one number doing two unrelated jobs. That period
exists so re-association can still find a track seconds later, and borrowing it
as a render timer held a frozen box on screen for five seconds while the real
object drove out from under it. It also corrupted every metric computed over
the recorded stream, as D1.2 found.

Split out deleted_track_render_fade, default 500ms. Re-association is
untouched: the track stays in the map for the full grace period, it simply
stops being drawn.

Measured exactly on run baf20f02, since the fade is a pure filter on
publication and needs no pipeline re-run: published DELETED track-frames fall
from 10,610 (45.8 per cent of all track-frames) to 1,170 (5.0 per cent), an 89
per cent reduction in ghost frames.

Both parameters follow the D1.3 pattern through the strict config schema: the
three shipped tuning files, config-migrate defaults, and the runtime tuning
endpoint's accepted keys. GetRecentlyDeletedTracks now windows on the render
fade, so its tests are renamed to say so rather than asserting the old
semantics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… 1 gate

A VRLOG stores the decisions the pipeline already made, so replaying one shows
what the old code concluded, not what the new code would conclude. Measuring a
change to L4, L5 or L6 means re-running perception over the packets, and the
only route to that was the live server's replay endpoint, which binds ports and
shares state with whatever else the server is doing. D1.3 and D1.5 were
therefore proven on synthetic sequences but unmeasured on real data.

Add internal/lidar/replayeval, exposed as `velocity lidar pcap-replay`. It
replays a PCAP through the full L1-L6 pipeline and records a VRLOG with no
server, no database and no listening port, following the pattern settlingeval
already established for L3. Because the output is an ordinary VRLOG, nothing
downstream needed changing: GenerateReport for metrics, CompareReports for A/B,
and the macOS visualiser for looking at it. --compare-to runs all three.

Sixty seconds of capture replays in about 12 seconds. The frame-rate throttle
is off, because dropping frames would make two runs disagree for reasons
unrelated to the change under test. Point clouds are omitted unless
--include-points is passed: 45 s of the SoMa capture is 347 MB with them and
9.1 MB without. Track persistence is disabled explicitly rather than by leaving
the DB nil, so a future pipeline change that starts assuming a database fails
loudly here instead of writing into the production store.

Day 1 gate, both arms replaying the same PCAP through the same binary and
differing only in the tuning file. The before arm restores the pre-sprint
behaviour: no lock release, no fragment guard, render fade back on the 5 s
grace period.

  median per-track course error   40.8 -> 28.3 to 29.6 deg   (-28%)
  locked share of live frames     24.8% -> 16.1 to 16.6%     (-34%)
  longest single lock run         70 -> 20 frames            (-71%)
  published DELETED track-frames  8,617 (54.0%) -> 951 (11.4%)  (-89%)
  total published track-frames    15,970 -> 8,377            (-48%)

Four qualifications recorded in the plan rather than buried. The trapped count
barely moves because LockTrapped needs five consecutive unlocked frames to
clear, and once locks start breaking they also re-form, so the flag stays set;
the longest-run collapse is the direct evidence the ratchet is gone, and the
metric needs splitting before it is used as a gate. Twenty frames is still two
seconds of wrong heading, which is the case for D2.1. Fragmentation rises
slightly, as intended, because a scrap now seeds its own short track instead of
corrupting a vehicle's. And frame assembly is not bit-deterministic, so a
regression fixture will need a tolerance rather than an equality.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pressure

The harness was silently dropping frames. FrameBuilder discards a frame when
the callback channel is full, which is correct for a live sensor and wrong for
offline analysis: the PCAP reader outruns clustering and tracking, so frames
were lost at whatever rate the machine happened to impose. Two runs of the same
capture disagreed on frame count (588 to 592), median course error (28.3 to
30.2 degrees) and trapped tracks (30 to 35), which is fatal for an A/B because
the change under test cannot be separated from the scheduler.

pcapsplit and the server's own analysis mode both call SetBlockOnFrameChannel
for exactly this reason. The FrameBuilder's own comment on the blocking path
says the buffer would otherwise deliver "only ~12% of the rotations". The
harness now does the same.

With back-pressure the harness is reproducible to the digit: three consecutive
runs agree on frame count, track count, every percentile and the trapped count.
Frame count rises from about 588 to 601, so roughly 13 frames per run were
being discarded.

The A/B was re-run and the conclusions survive unchanged, because both arms
were losing frames at the same rate:

  median per-track course error   40.8 -> 29.7 deg          (-27%)
  locked share of live frames     24.5% -> 16.0%            (-35%)
  longest single lock run         70 -> 20 frames           (-71%)
  published DELETED track-frames  8,639 (53.9%) -> 942 (11.3%)  (-89%)

Also confirms the fragmentation mechanism rather than asserting it. The ratio
rises from 0.301 to 0.313, and the census shows why: four more tracks under
half a second, and six fewer tracks whose average bounding box is under half a
metre. Six vehicle tracks that were previously shrunk to fragment size no
longer are. The ratio counts tracks, not correctness.

An earlier attempt pinned the frame-assembly timers on the theory that
wall-clock frame cutting was the cause. That was wrong and is not kept.

TestRunFrameCountIsStableAcrossRuns was passing on luck with a short window.
Replaced with TestRunIsDeterministic, which compares frame count, track count
and fragmentation across two runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carried over from another worktree, where it had been left uncommitted rather
than on a branch. One new proposal plus the two cross-references that make it
reachable: an entry in the MATHS.md proposal roadmap and a research-extension
note at the top of classification-maths.md.

The proposal argues for one semantic vocabulary and evidence contract across L3
to L7, with specialised estimators staying where they are, and separates class,
behaviour, and measurement quality into independent attributes so that a
stationary car does not become street furniture. It is explicitly research, not
active in the runtime, and says plainly that no model has been evaluated on our
captures yet.

Checked before taking it: no broken relative links, and the six citations
resolve to real repositories and papers rather than constructed identifiers.
The header-metadata hook stripped a Date key, which the house format does not
allow, and folded the research-extension note into the metadata list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ddol and others added 29 commits September 19, 2026 01:01
…itivity findings

Batch 1 (L3 background-settling broad sweep, 24 sites, 408 runs) finished
on its own: neighbour_confirmation_count and noise_relative are genuinely
sensitive (21/24 and 6/24 sites regress at their tested extremes, one site
never converges), closeness_multiplier and safety_margin_metres are robust
across the full corpus. Deterministic rule and full numbers in
sensitivity-analysis.json / analyze_sensitivity.py. results.csv/summary.json
are still being appended to by the in-progress replicate pass (Batch 2) and
follow in the next commit once that settles.

Adds a small supervisor (data/experiments/try/campaign/) that reuses this
harness rather than building a new one: waits for the operator-launched
Batch 1 process (never starts/restarts it), then runs the narrowed sweep,
replicate pass, and interaction grid this analysis justifies, attempts the
L5 ground-truth-scored sweep, and stops after a configurable wall-clock
budget. manifest.json is the editable future-work list; status.json is the
compact check-in rollup.

Also lands cmd/tools/lidar-ground-truth-eval, a tested standalone CLI over
the already-implemented adapters.EvaluateGroundTruth (previously reachable
only through the live HINT HTTP flow), needed to score the L5 sweep without
the circular alignment metric the earlier preliminary pass used. Running it
against real replayed candidates is blocked on a confirmed architecture
conflict: the live server couples the PCAP-replay BPF filter port to the
live-listen bind port with no override, and the operator's dev server
already holds the port the reference capture needs. Diagnosed by hand
(cmd/tools/lidar-ground-truth-eval/main.go, run_l5_gt_sweep.py, and Batch 3
in the campaign plan have the exact code references); the sweep script
self-blocks with a written reason rather than guessing past it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t pass

save_manifest/write_status and the four analysis/planning scripts' JSON
writers were missing a trailing newline, which the pre-commit
end-of-file-fixer hook flags every time -- caught chasing it across several
commit attempts. Also records the supervisor's first real pass against this
manifest: wait_l3_batch1 confirmed genuinely done (408/408 rows), the three
Go tools built successfully. status.json is the compact rollup a check-in
reads first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eep to multiple sites

Pass 1 of the unattended campaign finished cleanly (11/11 stages done,
~1.53h) but review at check-in found two defects a clean exit code had
hidden: plan_interaction_levels.py cast neighbour_confirmation_count's
worst value to float, so settling-eval's strict Go unmarshal rejected it
and 48/96 interaction-grid rows silently errored while the stage still
recorded "done". Fix the cast (plus a defensive int-cast in
run_interaction_grid.py) and add a supervisor guard that fails the stage if
any combo has zero non-error rows instead of trusting the exit code alone.

Also found the L5 GT sweep's numbers were a windowing artifact: it replayed
only 60s of kirk1.pcapng's 177.8s capture, so most of the 49 labelled
reference tracks fell outside the window regardless of tuning. Add
out_dir plumbing to the L5 stage handler and queue a full-duration rerun
plus two more labelled reference sites (MIN_POSITIVE_LABELS=8 floor) so L5
evidence isn't drawn from a single recording, with a cross-site ranking
check (analyze_l5_results.py).

Extends manifest.json with 7 new stages; no terminal stage from pass 1 is
modified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…3 keys, repeat check

Second-pass review found the L3 interaction verdict overstated (two sites
scored "additive" only because the replay window capped them; the grid used
noise_relative=0.05 though the never-analyzed 0.065 rows flag 13/24 sites),
settling-eval non-deterministic at slow sites (reruns disagreed with Batch 1
at 3/48 comparisons), and the L5 noise grid blind by construction:
EvaluateGroundTruth matches on temporal IoU only, so process/measurement
noise is invisible to it.

Adds a ground-truth one-at-a-time sweep over keys that change track
existence (L4 eps/min points, L5 hits_to_confirm/max_misses/gating), with
every POST verified against the live config and a discarded warm-up plus
start/middle/end baselines, because the first replay after server start is a
cold outlier. Extends L3 to 8 unswept keys settling-eval actually consumes
(it forces warmup and settling_period, so those are excluded). Adds a repeat
check, censoring-aware interaction analysis, inert-key detection, and
param_types.coerce at every config write to close the int/float bug class.
Queues 16 stages as a strict priority chain, budget 7h.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…weeps

The L3 results.csv (Batch 1 + narrowed + replicate), interaction-results.csv
(including the 48 rows voided by the neighbour_confirmation_count=1.0 type bug,
kept as history), and the four L5 ground-truth sweep CSVs were never
committed because data/**/*.csv is gitignored. Force-added so the evidence the
campaign's conclusions rest on is reproducible from git.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… concurrency guard

pgrep -f matched any process merely mentioning run_sweep.py, so the shell that
launched the supervisor made l3_interaction_grid_v3 report "another L3 driver
is active" on its first pass. The same would happen with an operator's
`less run_sweep.py`, silently stalling the campaign. Require a Python
interpreter followed by the script as a whole path component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Updated the `updated_at` timestamp to reflect the latest data.
- Increased `total_rows` from 823 to 1139.
- Added new metrics for various parameters including `background_update_fraction`, `seed_from_first`, `post_settle_update_fraction`, `reacquisition_boost_multiplier`, `min_confidence_floor`, `locked_baseline_threshold`, `locked_baseline_multiplier`, and `freeze_threshold_multiplier`.
- Provided detailed statistics for each new parameter, including number of runs, convergence status, and settling frame metrics.
- Updated timestamp to reflect the latest data collection.
- Increased total rows from 1149 to 1358.
- Adjusted metrics for background_update_fraction, seed_from_first, post_settle_update_fraction, and freeze_threshold_multiplier based on new runs.
- Notable changes include:
  - background_update_fraction: n_runs increased to 235, n_not_converged to 4, mean_settling_frame adjusted to 164.7.
  - seed_from_first: n_runs increased to 47, n_not_converged remains at 47.
  - post_settle_update_fraction: n_runs increased to 141, mean_settling_frame adjusted to 61.1.
  - freeze_threshold_multiplier: n_runs increased to 22, mean_settling_frame adjusted to 11.5.
… rows, queue pass 4

l3_extended_sweep_b lost 344 of 456 rows and was recorded done: a commit ran the
mixed-line-ending hook, which replaced results.csv while run_sweep.py held it
open, so later rows went to the unlinked file. All raw reports survived.

- row_appender.RowAppender reopens the CSV for every row; all five sweep
  drivers use it
- run_l3_sweep fails a stage when a planned value has fewer rows than sites
- recover_rows_from_raw.py rebuilds rows from raw reports, verifying capture,
  config value and timestamp window; supervisor stage type recover_raw_rows
- plan_interaction_levels --policy mildest, and drops keys no site converges
  at (seed_from_first=false made the top-2 grid score 0 of 24 sites)
- analyze_interaction_grid splits lost convergence into joint_only/single_key
- manifest: 9 stages, budget_hours 2.5 (remainder of the 7h)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… sweep of the five unseen L3 keys

The supervisor saved the manifest it read before a stage started, discarding any
edit to a pending stage made while a long stage ran. It now reloads first.

The recovered 24-site data shows frames identical across every tested value of
reacquisition_boost_multiplier, min_confidence_floor, locked_baseline_*,
freeze_threshold_multiplier, so settling-eval cannot see them; queue a
ground-truth one-at-a-time sweep of their extremes on kirk0 and kirk1 instead,
ahead of the low-value ordinal-1 replicate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Updated timestamp to reflect the latest data collection.
- Increased total rows from 1358 to 2139.
- Adjusted metrics for various parameters including:
  - reacquisition_boost_multiplier: n_runs from 24 to 188, mean_settling_frame from 11.5 to 61.2, max_settling_frame from 12 to 722.
  - min_confidence_floor: n_runs from 24 to 188, mean_settling_frame from 11.5 to 60.4, max_settling_frame from 12 to 717.
  - locked_baseline_threshold: n_runs from 24 to 188, mean_settling_frame from 11.5 to 60.1, max_settling_frame from 12 to 723.
  - locked_baseline_multiplier: n_runs from 18 to 141, mean_settling_frame from 11.5 to 60.1, max_settling_frame from 12 to 712.
  - freeze_threshold_multiplier: n_runs from 22 to 188, mean_settling_frame from 11.5 to 61.0, max_settling_frame from 12 to 723.
…-window L3 stages, queue pass 5

Every raw settling report holds the four convergence criteria at every frame;
only the first converged frame was ever read. analyze_post_settle.py measures
whether a setting keeps the grid settled (self-check: the recorded frame equals
the first all-criteria frame for 2078/2078 rows) and drops exact-equality
"inert" tests, which are invalid because no two runs have identical per-frame
metrics.

run_gt_oat_sweep.py --label-free records track statistics for captures with no
labelled reference (reproduces the scored candidate counts exactly), with
analyze_label_free_oat.py comparing directions against the labelled captures
under the GT analyzer's reliability and threshold rules. run_l3_sweep takes
out_dir/ordinal and guards _baseline row counts; the supervisor refuses to
start a stage with under min_free_gb free.

Manifest: 37 stages, budget 8.6 h.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Enhance Makefile with new targets for scene asset management:
  - `scene-assets`: Rebuild outstanding scenes and update pages.
  - `scene-assets-status`: Report on published and outstanding scenes.
  - `scene-assets-clean`: Remove `.rebuilt` markers to trigger rebuilds.
  - `scene-corpus-verify`: Validate corpus against its manifest.

- Introduce `publish-scenes.py` for generating web scene assets from the trimmed corpus, allowing for reproducible scene exports.

- Implement `verify-corpus.py` to check the integrity of the corpus, ensuring all captures are present and match expected sizes and digests.

- Add unit tests for `publish-scenes.py` and `verify-corpus.py` to ensure correct functionality and error handling.

- Update documentation to reflect new features and usage instructions for scene asset management.
- Changed the path of the frozen artefact to reflect its actual location on the capture volume.
- Added detailed information about the frozen artefact, including size, SHA-256 checksum, and schema versions.
- Expanded the section on header fields to clarify extraction parameters and provenance.
- Provided a comprehensive breakdown of candidate records, including their structure and review statuses.
- Included a Python command for selecting the frozen set from the candidates.
…a-normalised bg re-score

Reviewed against the 2026-09-19 gap-analysis revision after two stages: the
default config's median track lifetime is 0.37 s on Columbus-Broadway, and
closeness_multiplier=1.5 recovers 13 of 16 labelled tracks on kirk0 against a
baseline of 7. Cut the low-information L3 confirmation stages (300 s
guardrails at o3, second hold-out, six label-free segments), added the
acceptance-window dose-response against labels and B7's closeness 7.5/9.75
prediction, and recorded track statistics and mean matched IoU in scored mode
so recall can be told from fragmentation.

analyze_alpha_normalised.py re-scores the background_update_fraction sweep
with the spread-delta criterion scaled by alpha: 0 of 24 sites flagged at
every value in both windows (was 6/17/24), so that verdict was the criterion.

Pass 6 (launch_pass6.py waits for the pass-5 supervisor to exit, merges
pass6-stages.json, relaunches): run_nis_sweep.py drives
lidar-state-estimation-baseline -tuning per config and site, offline and
repeat-verified, reading per-band NIS from tracking_baseline.json and
removing the recordings; analyze_nis_sweep.py measures distance from the
consistency targets per band and never minimises. first-segment-corpus.json
declares one capture per case, because the tool digests every declared
capture before it replays (641 s for a 20 s window at Columbus otherwise).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit fa89e3a)
The baseline tool nests the case id under -out, so the frames and index live
two levels down; the cleanup looked one level up and removed nothing (the
smoke test recorded recordings_removed=0). Verified on the smoke output: four
items removed per config, every JSON kept, 12 MB to 92 KB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 8100df7)
The 20 s smoke test at Columbus produced 43 and 41 accepted observations in
the 2 and 5 m/s bands, so 120 s of scoring risked leaving the moving bands
under the analyzer's floor. 70 s warm-up plus 200 s scoring fits the 300 s
first segment; at 300 observations the standard error of a chi-squared(2)
mean is 0.12, under the 0.10 log-ratio floor at NIS 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit ded4a54)
- Updated timestamp to reflect the latest data collection.
- Increased total rows from 2139 to 2539.
- Adjusted parameters for baseline and various metrics:
  - Baseline runs increased from 47 to 69 with a mean settling frame change from 58.4 to 53.0.
  - Closeness multiplier runs increased from 188 to 236; mean settling frame updated to 66.5.
  - Noise relative runs increased from 212 to 322; mean settling frame updated to 69.4.
  - Neighbour confirmation count runs increased from 188 to 276; mean settling frame adjusted to 101.1.
  - Background update fraction runs increased from 235 to 345; mean settling frame updated to 157.4.
  - Seed from first runs increased from 47 to 69, with all not converged cases updated accordingly.

(cherry picked from commit eed1b9a)
…riptions, add new findings from recent experiments, and clarify association cost impacts on trajectory quality.

(cherry picked from commit cc35de2)
…eclassify gaps, and clarify mathematical discrepancies.

(cherry picked from commit fc05db5)
…easurements and corrections

(cherry picked from commit a8179f9)
… rule; pin the gap-analysis tests; E1.2/E1.4 analysis

Three TrackerConfig/DBSCANParams options, all default false so each is
measured against the shipped behaviour before it ships:

- CascadedAssociation (S2/S3): confirmed tracks are matched first and
  tentative tracks compete only for what they leave, after DeepSORT's
  cascade. The joint assignment let a one-frame-old tentative track outbid a
  confirmed track coasting through one miss (test pins both behaviours).
- ScaleMinPtsWhenSubsampled (D6): when the input cap subsamples a frame,
  MinPts scales with the kept fraction so the busiest frames keep the density
  threshold the quiet ones run at; plumbed as pipeline DensityPreservingCap.
- OBBHeadingFlipRule (P3): AB3DMOT's orientation correction before Guard 3;
  without it a stationary track whose PCA sign alternates walks its heading
  26.6 degrees in 40 frames (test pins that too).

Tests for D1, D2, D3, H1, H2, K4 (the extent cost's first evaluation on
unlike-class neighbours: swap at weight 0, kept apart at 1), V1 (quadratic
coast error), B1 (spread converges to 0.798 sigma) and B2 (settled
spread-delta floor is 0.483 alpha sigma).

lidar-e1-analysis gains E1.4 (stationary noise floor per candidate and range
band against the configured sigma_R) and E1.2 (Ljung-Box whiteness of the
lateral residual on straight segments, for the four candidates and the
filter posterior).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit aa9617fb6507f3ab7a41bb41373cd1a9385facd2)
…loor, red lateral residuals

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit bd6a03c4e78322dda43476f184dc05e0ff35463a)
@ddol
ddol force-pushed the dd/docs/state-est branch from a8179f9 to 5ab136d Compare September 19, 2026 22:01
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