diff --git a/openpilot/starpilot/flm/live_tracking.py b/openpilot/starpilot/flm/live_tracking.py index 3618895314..d2a7606f80 100644 --- a/openpilot/starpilot/flm/live_tracking.py +++ b/openpilot/starpilot/flm/live_tracking.py @@ -1484,10 +1484,17 @@ class LiveTrackingOptimizer: and 0 <= now_ns - self.last_ack[-1] <= 100_000_000 ) status, reason = self.status, self.reason + paused = self.paused_intent + if (self.paused_demand_context != (self.binding, self.certificate, self.domain) + or paused is None or paused[0]['baseline'] != self.accepted or type(now_ns) is not int or now_ns >= paused[0]['deadline_ns']): + paused = None + before, future = (self.before, self.future) if paused is None else paused[1:] if state is not None and state.get('accepted', False): if acknowledged: status = state['phase'] reason = 'accepted_target_currently_acknowledged' if status == 'confirmed' else 'prior_accepted_baseline_restored' + if status == 'rollback' and paused is not None and self.paused_baseline_ns > 0: + reason = 'original_input_regime_after_baseline_ack_required' else: state = None @@ -1503,14 +1510,14 @@ class LiveTrackingOptimizer: 'trialId': None if state is None else state['trial_id'], 'phase': None if state is None else state['phase'], 'requiredEpisodes': None if state is None else state['required_episodes'], - 'beforeEpisodes': len(self.before), - 'futureEpisodes': len(self.future), + 'beforeEpisodes': len(before), + 'futureEpisodes': len(future), 'modelStatus': self.model_status if current else 'stale', 'lagMs': self.lag_ns / 1e6, - 'baselineErrorRms': mean(self.before, 'error_rms'), - 'futureErrorRms': mean(self.future, 'error_rms'), - 'baselineReversalHz': mean(self.before, 'reversal_hz'), - 'futureReversalHz': mean(self.future, 'reversal_hz'), + 'baselineErrorRms': mean(before, 'error_rms'), + 'futureErrorRms': mean(future, 'error_rms'), + 'baselineReversalHz': mean(before, 'reversal_hz'), + 'futureReversalHz': mean(future, 'reversal_hz'), 'applicationAcknowledged': acknowledged, 'applicationSettledMonoTime': self.last_ack[-1] if acknowledged and self.last_ack is not None else self.settled_ns, 'lastCorrectionMonoTime': self.last_correction or None, diff --git a/openpilot/starpilot/flm/tests/test_staged_report.py b/openpilot/starpilot/flm/tests/test_staged_report.py new file mode 100644 index 0000000000..1612e3bc0e --- /dev/null +++ b/openpilot/starpilot/flm/tests/test_staged_report.py @@ -0,0 +1,49 @@ +"""Retained numerical progress must not become current target authority.""" + +from dataclasses import replace + +from openpilot.starpilot.flm.tests.test_staged_source_study import pause, baseline_ack + + +def test_held_progress_does_not_claim_target_or_confirmed_application(): + o, _original, scores, incoming = pause() + report = o.report(incoming.end_ns) + assert report['phase'] == 'rollback' and not report['applicationAcknowledged'] + assert (report['beforeEpisodes'], report['futureEpisodes'], report['requiredEpisodes']) == (3, 2, 3) + assert report['reason'] == 'trial_input_regime_ended' and not any(o.accepted.values()) + ack = baseline_ack(o, incoming.end_ns + 10_000_000) + report = o.report(ack) + assert report['status'] == report['phase'] == 'rollback' + assert report['applicationAcknowledged'] and report['reason'] == 'original_input_regime_after_baseline_ack_required' + assert (report['beforeEpisodes'], report['futureEpisodes']) == (len(scores[0]), len(scores[1])) + assert report['lastCorrectionMonoTime'] is None and not any(o.accepted.values()) + + +def test_stale_baseline_does_not_report_current_restoration(): + o, _original, _scores, incoming = pause() + ack = baseline_ack(o, incoming.end_ns + 10_000_000) + report = o.report(ack + 100_000_001) + assert not report['applicationAcknowledged'] and not report['bindingCurrent'] + assert report['phase'] is None and report['modelStatus'] == 'stale' + + +def test_expired_changed_or_discarded_study_has_no_retained_progress(): + for change in ('expiry', 'certificate', 'baseline', 'manual'): + o, original, _scores, incoming = pause() + now = baseline_ack(o, incoming.end_ns + 10_000_000) + if change == 'expiry': + now = original['deadline_ns'] + elif change == 'certificate': + o.certificate = replace(o.certificate, model_revision='9' * 64) + elif change == 'baseline': + o.accepted['ff_gain_left'] = 0.001 + else: + o.interrupt('manual_override') + report = o.report(now) + assert report['beforeEpisodes'] == report['futureEpisodes'] == 0 + for invalid_now in (None, True, 'now', float('nan')): + o, _original, _scores, incoming = pause() + baseline_ack(o, incoming.end_ns + 10_000_000) + report = o.report(invalid_now) + assert report['beforeEpisodes'] == report['futureEpisodes'] == 0 + assert not report['applicationAcknowledged'] and not report['bindingCurrent'] diff --git a/openpilot/starpilot/galaxy/tests/test_flm_tracking_results.mjs b/openpilot/starpilot/galaxy/tests/test_flm_tracking_results.mjs index 9f58aac0f7..3515ce71bd 100644 --- a/openpilot/starpilot/galaxy/tests/test_flm_tracking_results.mjs +++ b/openpilot/starpilot/galaxy/tests/test_flm_tracking_results.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { compile } from '../web/vendor/vue/vue.esm-browser.js' import { FlmPage } from '../web/js/flm.js' -import { SURFACE_NAMES, PID_UNSUPPORTED, validTrainingResults, validTrackingResults, trackingView, surfaceRows, measurementText, validTrackingValues, trackingCoordinates, validTrackingPlot, trackingPlotView, sourceSeconds } from '../web/js/flm-training-results.js' +import { SURFACE_NAMES, PID_UNSUPPORTED, validTrainingResults, validTrackingResults, trackingView, surfaceRows, measurementText, validTrackingValues, trackingCoordinates, validTrackingPlot, trackingPlotView, sourceSeconds, tunerStatus } from '../web/js/flm-training-results.js' const coordinates = amount => SURFACE_NAMES.map((name, i) => [name, i === 0 ? amount : 0]) const tracking = () => ({ version: 1, status: 'trial_active', reason: 'scoring_future_tracking', group: 'feedforward', @@ -60,6 +60,24 @@ const neutralRollback = report(); neutralRollback.current.tracking.status = 'rol neutralRollback.current.application.applied = false neutralRollback.current.details['Actual full surface coordinates'] = coordinates(0) assert.equal(trackingView(neutralRollback, true).label, 'Correction rollback acknowledged') +const held = structuredClone(neutralRollback) +held.current.tracking.reason = 'original_input_regime_after_baseline_ack_required' +held.current.tracking.futureEpisodes = 2 +assert.equal(trackingView(held, true).label, 'Baseline restored; waiting for matching road') +assert.equal(tunerStatus(held, true).title, 'Waiting for matching road') +assert.equal(trackingView(held, true).futureEpisodes, 2) +assert.equal(trackingView(held, true).requiredEpisodes, 3) +assert(!/confirmed|saved|correction applied/i.test(tunerStatus(held, true).title)) +for (const mutate of [value => value.current.tracking.applicationAcknowledged = false, + value => value.current.tracking.bindingCurrent = false, + value => value.current.fresh = false, + value => value.current.tracking.phase = 'trial', + value => delete value.current.details['Actual full surface coordinates']]) { + const noProof = structuredClone(held); mutate(noProof) + assert.equal(trackingView(noProof, true).acknowledged, false) + assert.notEqual(tunerStatus(noProof, true).title, 'Waiting for matching road') +} +assert.notEqual(tunerStatus(held, false).title, 'Waiting for matching road') for (const mutate of [value => value.current.tracking.applicationAcknowledged = false, value => value.current.tracking.bindingCurrent = false, value => value.current.tracking.applicationSettledMonoTime = null, diff --git a/openpilot/starpilot/galaxy/web/js/flm-training-results.js b/openpilot/starpilot/galaxy/web/js/flm-training-results.js index 1912b8a166..0e40f312b6 100644 --- a/openpilot/starpilot/galaxy/web/js/flm-training-results.js +++ b/openpilot/starpilot/galaxy/web/js/flm-training-results.js @@ -175,6 +175,7 @@ const REASONS = { trial_oscillation_batch_worse: "Comparable trial windows showed more oscillation, so the correction was rolled back.", predeclared_independent_future_look_required: "Collecting the next planned set of comparison windows.", prior_accepted_baseline_restored: "The controller returned to its prior baseline.", + original_input_regime_after_baseline_ack_required: "The baseline is restored. Completed comparison samples are kept while waiting for the same driving conditions.", scoring_later_matched_whole_episodes: "Comparing new steering windows with the baseline.", accepted_target_currently_acknowledged: "The controller acknowledged the accepted correction.", } @@ -204,10 +205,12 @@ export function tunerStatus(report, live = false, choiceLive = false, error = "" if ((tracking?.reason || current.reason) === "controller_collection_required") return { key: "waiting", tone: "muted", title: "Waiting for active steering", description: learningReason("controller_collection_required") } if (tracking && !tracking.bindingCurrent) - return { key: "paused", tone: "warning", title: "Learning paused", description: "The steering context changed. A fresh qualified window is required." } + return { key: "paused", tone: "warning", title: "Learning paused", description: "Current steering evidence is unavailable. A fresh qualified window is required." } if (tracking?.acknowledged && tracking.status === "confirmed") return { key: "confirmed", tone: "success", title: "Correction applied", description: "The comparison passed and the controller acknowledged this correction. Disk saving is a separate result." } if (tracking?.acknowledged && tracking.status === "trial_active") return { key: "testing", tone: "active", title: "Testing a small correction", description: "The controller acknowledged the trial. Comparable new driving windows decide whether to keep it." } if (tracking?.status === "trial_pending") return { key: "pending", tone: "active", title: "Waiting for the controller", description: "A trial was offered. It has not yet been acknowledged as applied." } + if (tracking?.status === "rollback" && tracking.acknowledged && tracking.reason === "original_input_regime_after_baseline_ack_required") + return { key: "waiting", tone: "ready", title: "Waiting for matching road", description: learningReason(tracking.reason) } if (tracking?.status === "rollback") return { key: "rollback", tone: "warning", title: "Returning to the baseline", description: tracking.acknowledged ? "The controller acknowledged the rollback." : "A rollback was requested. Current application is not yet acknowledged." } if (tracking?.status === "paused" || current.status === "unavailable") return { key: "paused", tone: "warning", title: "Learning paused", description: learningReason(tracking?.reason || current.reason) } return { key: "collecting", tone: "active", title: tracking?.status === "calibrating" ? "Measuring steering response" : "Collecting driving samples", description: learningReason(tracking?.reason || current.reason) } @@ -256,8 +259,9 @@ export function trackingView(report, live = false) { if (value.status === 'trial_active' && acknowledged) label = 'Evaluating correction' if (value.status === 'confirmed' && acknowledged) label = 'Confirmed correction acknowledged' if (value.status === 'rollback' && acknowledged) label = 'Correction rollback acknowledged' + if (value.status === 'rollback' && acknowledged && value.reason === 'original_input_regime_after_baseline_ack_required') label = 'Baseline restored; waiting for matching road' if (value.status !== 'paused' && (value.phase !== null && !matchingPhase || expectedPhase !== undefined && !matchingPhase)) label = 'Learning state reported; trial application unknown' - if (live && !value.bindingCurrent) label = 'Learning paused; steering context changed' + if (live && !value.bindingCurrent) label = 'Learning paused; current steering evidence unavailable' if (!live) label = 'Earlier learning report; current application unknown' return { ...value, label, acknowledged, changedParameters: value.names.map(readable).join(', '), measurements: [ diff --git a/prebuilt.json b/prebuilt.json index cce9326842..ff4a0c1684 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "9653f67cf25c7e37488533864943c5949cca94b4c7dadbe1ad1af400eb36d88d", "schema_version": 1} +{"protected_inventory_sha256": "a65a390042e7d02f1180f79f6ad3e95c80ce96be9f44cc1230ff7afe2fad885c", "schema_version": 1}