From ba71106544916fd8aafef8292ff3d778d20768cc Mon Sep 17 00:00:00 2001 From: legop3 Date: Mon, 28 Sep 2026 23:22:15 -0400 Subject: [PATCH] slop --- plans/rover learning service.md | 107 ++++++++++++-- .../roverLearningService/capabilities.js | 5 +- .../roverLearningService/configuration.js | 2 +- .../roverLearningService/diagnostics.js | 9 +- .../services/roverLearningService/driving.js | 10 +- .../services/roverLearningService/models.js | 2 + .../services/roverLearningService/training.js | 6 +- .../roverLearningService/workers/dataset.py | 61 +++----- .../roverLearningService/workers/infer.py | 62 +++------ .../roverLearningService/workers/policy.py | 55 ++++++-- .../roverLearningService/workers/train.py | 131 ++++++++++-------- webui/package.json | 2 +- .../components/vip/VipRoverLearningCard.jsx | 52 +++---- 13 files changed, 300 insertions(+), 204 deletions(-) diff --git a/plans/rover learning service.md b/plans/rover learning service.md index 0bfe8b3c..cb0ee243 100644 --- a/plans/rover learning service.md +++ b/plans/rover learning service.md @@ -2,18 +2,27 @@ ## Resume here -Latest change: one ongoing learner, gated Latest/Previous checkpoints, and -admin-created permanent snapshots replace automatic permanent publications. +Latest change: format 3 command-event policy replaces format 2 continuous wheel +speeds. All supported controls, including drive and recorded zero-speed stops, +use scored command templates and the same threshold/checked command pipe. The +network produces scores and numeric values; it never generates JSON text. -Status: policy format 2 now predicts continuous wheel speeds plus accessory events. -Copyable diagnostics and main-card wheel/action displays with a session threshold -slider are implemented. Older model files remain stored but cannot run in this format. -Recording, bounded dataset/training, permanent named models, live inference, -checked autonomous command submission, and the verified VIP card are coded. -Human commands do not stop or pause autonomy (explicit latest user requirement). -Focused local checks passed; actual recording/training, live autonomous behavior, -concurrent server performance, and browser appearance remain unverified. -The standalone CPU benchmark was run successfully on the actual server. +Implemented: remove wheel-speed telemetry and previous drive-command history +from inputs (raw recordings unchanged); preserve exact numeric zeros; reuse +recordings with a fresh resume-v4.pt / schedule-v4.json lineage. Existing format 2 +snapshots remain stored and are visibly incompatible. One ongoing learner, gated +Latest/Previous, and admin-created permanent snapshots remain. + +Main card shows decoded command parameters, score, applied threshold marker, and +submission results, including below-threshold proposals. Diagnostics reportVersion +3 includes command predictions/submissions and event evaluation. Manual inputs +still never stop or pause autonomy. Disabled remains dormant/off by default. + +Focused Node syntax, Python production worker entry points, and card ESLint +passed. New policy: 803,440 parameters, 30 sensor fields, stateSize 700. No live +recordings/camera/rover pipeline or authenticated browser were available locally. +Actual training, stop behavior, concurrent performance and visual appearance +remain unverified. No tests were added and no processes were left running. This document preserves the conversation's decisions across compactions and usage cutoffs. Keep the checklist and handoff section current during authorized @@ -30,9 +39,8 @@ The user declined the manual server checklist and explicitly asked to continue. Do not block further work or repeatedly ask them to do it. Preserve the unverified status and have the reader reject unusable recordings automatically. -Latest increment: internal card gating, dock lead-in/tail capture, live prediction -diagnostics, ongoing retained-data sampling, and gated model publication are coded. -See the final handoff section for settings and the new training lineage. +Latest increment: see the command-event policy handoff at the end. Earlier +format 2 wheel-specific design notes below are historical and superseded. Next work: verify the integrated feature against actual recordings and the real camera/rover pipeline when that environment is available; inspect the VIP card in @@ -835,3 +843,74 @@ scheduled. Selection guarantees a held-out recording, not usable evaluation windows: decoding/vocabulary/sample checks still apply in the worker. Node syntax checks and diff whitespace checks passed; actual job selection on production recordings remains unverified. No new tests or background processes. + +### Active implementation: command-event policy (authorized IPI) + +Supersedes format 2 continuous wheel output. The model predicts numeric command +scores and payload values; ordinary code constructs commands for the existing +shared command pipe. It does not generate JSON text. Drive, recorded key-release +stops, and accessories all use the threshold. Remove wheel-speed sensors and +previous drive-command inputs, but preserve raw recordings. Exact zero values +remain template constants so a demonstrated stop cannot regress into creeping. + +Keep one continuing learner, Latest/Previous, admin snapshots, recording lifecycle, +permissions, and manual inputs that never cancel autonomy. No prescribed human +driving. Start a new format 3 lineage, reuse recordings, show older snapshots as +incompatible. No model-size increase or recording redesign in this change. + +Update the upper card to show every command proposal with decoded parameters, +score, threshold marker and dispatch result, including below-threshold proposals. +Update copied diagnostics and evaluation for command events, parameter error, +missed stops and unwanted commands. Evaluate more than one held-out recording; +event validation is not proof of autonomous driving or physical rollout quality. + +Progress: implemented; focused checks passed. User's unrelated webui/package.json +edit was preserved. No changes to runtime recording, disabled startup, control +permissions or manual-input override semantics. + +Implementation details for resume: +- workers/policy.py VERSION=3, CNN/GRU unchanged in size; two heads for scores and + command parameters. Shared select_slots chooses one winner per command family + (peripheral id/control are separate families). No unconditional drive head. +- Numeric zeros and address fields stay exact template constants. Stop is the + normal recorded driveDirect {left:0,right:0} command. Unknown templates still + reject explicitly at the 64-shape limit. Arrays remain exact demonstrations. +- workers/dataset.py yields five arrays: images, state, event labels, parameters, + available shapes. It includes accepted human drive events, excludes ambiguous + conflicting events in the same 100ms window, and no longer requires an inferred + held wheel-speed target. Raw recorded sensors/commands are unchanged. +- Training reserves separate bounded samples for stop events, other commands and + no-command windows. Evaluation remains uniformly sampled. Scheduler selects + up to three training and two held-out recordings under the unchanged SHA split. +- Gate: complete evaluation, >=2 usable held-out sessions, >=128 windows, >=32 + command events, >=8 stops, >=16 movement events; command precision/recall, stop + recall and movement recall each >=0.5; normalized numeric MSE <=0.1 where + applicable. Diagnostics expose each failing reason and per-command counts. + These are experimental screening thresholds, not a guarantee of useful driving. +- New optimizer/scheduler filenames prevent silently reusing format 2 weights or + old rejected-session bookkeeping. No config values need changing. Existing + permanent format 2 snapshots are preserved, but cannot start; attempting to + snapshot an old candidate reports that a new command-policy checkpoint is needed. +- Live inference keeps existing discrete toggle latching and common command limits. + The threshold applies to drive and stop just like the other command predictions. + No-event means send nothing: it DOES NOT implicitly stop an already moving rover. + It must learn explicit stop events; removing the copy shortcut is not proof that + learning will succeed. Other motion-related sensors still remain as observations. +- Upper card replaces separate wheel meters with generic scored proposals and + decoded parameters; shows applied threshold and post-clamp submitted parameters. + Copied diagnostics retain top eight proposals and up to 16 submissions per + sampled controller-history entry, including their command objects. + +Verification: actual train.py --check-dependencies loaded Torch/NumPy/PyAV and +constructed format 3 (803,440 parameters); infer.py --help loaded successfully; +Node --check passed for six changed service modules; targeted card ESLint passed. +No new tests or throwaway programs. Live end-to-end training/inference and browser +visual verification require the actual environment and have not been performed. + +Deferred explicitly: chronological commanded-motion replay/closed-loop evaluation, +prepared frame caching and larger architectures. Current evaluation uses held-out +100ms event windows with the live threshold/family selection, but cannot assess +temporal latching or accumulated motion from random reservoir samples. It does +not claim to verify actual physical command execution. After normal deployment, +let the new learner process retained recordings; copied reportVersion 3 diagnostics +provide the next evidence. Do not demand prescribed driving or a manual checklist. diff --git a/server/src/services/roverLearningService/capabilities.js b/server/src/services/roverLearningService/capabilities.js index 6a695256..977ae895 100644 --- a/server/src/services/roverLearningService/capabilities.js +++ b/server/src/services/roverLearningService/capabilities.js @@ -1,5 +1,6 @@ // Match actuator configuration, not a rover's display name or changing telemetry. const { isDeepStrictEqual } = require('node:util'); +const POLICY_VERSION = 3; function controlProfile(meta = {}) { return Object.fromEntries(['cameraServo', 'peripherals', 'headlight', 'laser', 'roomba', 'platform'] @@ -7,9 +8,9 @@ function controlProfile(meta = {}) { } function compatible(model, record) { - if (model?.specification?.version !== 2 || !record) return false; + if (model?.specification?.version !== POLICY_VERSION || !record) return false; const profile = controlProfile(record.meta); return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(saved, profile)); } -module.exports = { controlProfile, compatible }; +module.exports = { POLICY_VERSION, controlProfile, compatible }; diff --git a/server/src/services/roverLearningService/configuration.js b/server/src/services/roverLearningService/configuration.js index a866f509..fd03ccda 100644 --- a/server/src/services/roverLearningService/configuration.js +++ b/server/src/services/roverLearningService/configuration.js @@ -20,7 +20,7 @@ module.exports = { enabled: boolean({ description: 'Enable human-session recording, background learning, and verified-user model control. Disabled starts no workers or storage.' }), driving: strictObject({ threads: integer({ minimum: 1, maximum: 16, description: 'CPU threads per active model controller.' }), - actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum predicted action probability for issuing a new control event.' }), + actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum model score for issuing a command, including drive and stop commands.' }), staleMs: integer({ minimum: 1000, maximum: 5000, description: 'Stop when video, sensors, or model predictions are older than this many milliseconds.' }), }, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }), recording: strictObject({ diff --git a/server/src/services/roverLearningService/diagnostics.js b/server/src/services/roverLearningService/diagnostics.js index 15ce36f4..9c9703e0 100644 --- a/server/src/services/roverLearningService/diagnostics.js +++ b/server/src/services/roverLearningService/diagnostics.js @@ -1,5 +1,6 @@ // Export an explicit allowlist, never the raw socket/session/configuration object. const { redact } = require('./errors'); +const { POLICY_VERSION } = require('./capabilities'); const os = require('node:os'); const fs = require('node:fs'); const path = require('node:path'); @@ -7,7 +8,7 @@ const { createHash } = require('node:crypto'); const applicationVersion = require('../../../package.json').version; const hash = createHash('sha256'); for (const file of ['workers/policy.py', 'workers/dataset.py', 'workers/train.py', 'workers/infer.py', - 'errors.js', 'storage.js', 'runtime.js', 'models.js', 'driving.js', 'recording.js', 'training.js', 'socketGateway.js', 'diagnostics.js']) { + 'capabilities.js', 'errors.js', 'storage.js', 'runtime.js', 'models.js', 'driving.js', 'recording.js', 'training.js', 'socketGateway.js', 'diagnostics.js']) { hash.update(fs.readFileSync(path.join(__dirname, file))); } const implementationSha256 = hash.digest('hex'); @@ -29,8 +30,8 @@ function report(state, controller) { const metrics = training.diagnostics || {}; const session = state.session; return { - reportVersion: 2, generatedAtUnixMs: state.updatedAt, - software: { applicationVersion, implementationSha256, policyVersion: 2, node: process.version, + reportVersion: 3, generatedAtUnixMs: state.updatedAt, + software: { applicationVersion, implementationSha256, policyVersion: POLICY_VERSION, node: process.version, dependencies: metrics.dependencies || null }, host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() }, settings: state.limits, @@ -52,7 +53,7 @@ function report(state, controller) { controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null, stopReason: redact(controller.stopReason), stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({ ...item, submissions: item.submissions.map((submission) => ({ type: submission.type, - wheels: submission.wheels, result: redact(submission.result) })) })) } : null, + command: submission.command, result: redact(submission.result) })) })) } : null, sensorFields: metrics.policy?.sensorFields || null, timing: { alignment: 'server_receipt', browserVideoLatencyMs: null, wheelCommandExpiryMs: null }, events: state.activity.map(({ at, message, detail, error, stderr }) => ({ atUnixMs: at, message: redact(message), detail: redact(detail), error: error || null, stderr: redact(stderr) })), diff --git a/server/src/services/roverLearningService/driving.js b/server/src/services/roverLearningService/driving.js index ccd1cfe9..23df69c9 100644 --- a/server/src/services/roverLearningService/driving.js +++ b/server/src/services/roverLearningService/driving.js @@ -127,21 +127,19 @@ function createDriving({ root, config, onChange, activity }) { session.latencyMs = message.latencyMs; session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : []; session.appliedThreshold = message.threshold; - session.wheelSpeeds = message.wheelSpeeds; session.sensorPresent = message.sensorPresent; session.submissions = []; if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch'); - session.predictionStatus = message.wheelSpeeds?.some((value) => value !== 0) ? 'Sending wheel controls' : 'Predicting stopped wheels'; + session.predictionStatus = message.commands.length ? `${message.commands.length} command(s) proposed` : 'No command proposed'; // Manual commands intentionally do not stop or pause this controller. for (const command of message.commands) transmit(session, command); // Bound report history independently of camera and UI frame rates. if (!session.history.length || Date.now() - session.history.at(-1).at >= 500) { - session.history.push({ at: Date.now(), wheelSpeeds: session.wheelSpeeds, + session.history.push({ at: Date.now(), latencyMs: session.latencyMs, frameAgeMs: Date.now() - session.frameAt, sensorAgeMs: Date.now() - session.sensorAt, threshold: session.appliedThreshold, - proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason }) => ({ slot, score, reason })), - submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result, - wheels: command.type === 'drive' ? command.driveDirect : undefined })) }); + proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason, command }) => ({ slot, score, reason, command })), + submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result, command })) }); if (session.history.length > 60) session.history.shift(); } } catch (error) { stop(roverId, error.message); break; } diff --git a/server/src/services/roverLearningService/models.js b/server/src/services/roverLearningService/models.js index ad9a794b..646dc823 100644 --- a/server/src/services/roverLearningService/models.js +++ b/server/src/services/roverLearningService/models.js @@ -1,3 +1,4 @@ +const { POLICY_VERSION } = require('./capabilities'); // Mutable aliases reference immutable checkpoints. All readers and retention share // a queue so a model cannot disappear between resolving its alias and loading it. const fs = require('node:fs/promises'); @@ -82,6 +83,7 @@ function saveSnapshot(root) { if (!UUID.test(state.candidate || '')) throw new Error('No completed learner checkpoint yet'); const source = path.join(checkpointRoot(root), state.candidate); const original = await metadata(source); + if (original.specification?.version !== POLICY_VERSION) throw new Error('Waiting for a completed command-policy checkpoint'); const id = randomUUID(); const words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']]; const name = `${words[0][randomInt(4)]}-${words[1][randomInt(4)]}-${id.slice(0, 8)}`; diff --git a/server/src/services/roverLearningService/training.js b/server/src/services/roverLearningService/training.js index c69b8ada..fcf97c23 100644 --- a/server/src/services/roverLearningService/training.js +++ b/server/src/services/roverLearningService/training.js @@ -33,7 +33,7 @@ function createTrainer({ root, config, actionThreshold, logger }) { } } try { - const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v3.json'), 'utf8')); + const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v4.json'), 'utf8')); ledger = saved.sessions; state = { ...saved.progress, status: 'waiting' }; if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule'); @@ -46,7 +46,7 @@ function createTrainer({ root, config, actionThreshold, logger }) { } async function saveLedger() { - const filename = path.join(trainingRoot, 'schedule-v3.json'); + const filename = path.join(trainingRoot, 'schedule-v4.json'); await fs.writeFile(`${filename}.tmp`, JSON.stringify({ sessions: ledger, progress: state })); await fs.rename(`${filename}.tmp`, filename); } @@ -83,7 +83,7 @@ function createTrainer({ root, config, actionThreshold, logger }) { partitions[heldOut ? 'heldOut' : 'training'].push(id); } const trainingSessions = partitions.training.slice(0, partitions.heldOut.length ? 3 : 4); - const heldOutSessions = trainingSessions.length ? partitions.heldOut.slice(0, 1) : []; + const heldOutSessions = trainingSessions.length ? partitions.heldOut.slice(0, 2) : []; const selected = [...trainingSessions, ...heldOutSessions]; const selection = { atUnixMs: Date.now(), diff --git a/server/src/services/roverLearningService/workers/dataset.py b/server/src/services/roverLearningService/workers/dataset.py index 84e4ac8b..fcb851d6 100644 --- a/server/src/services/roverLearningService/workers/dataset.py +++ b/server/src/services/roverLearningService/workers/dataset.py @@ -9,7 +9,7 @@ from pathlib import Path import av import numpy as np -from policy import SLOTS, VALUES, HEIGHT, WIDTH, encode_command, sensor_vector +from policy import SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, command_family, is_stop, encode_command, sensor_vector def load_events(directory): @@ -72,27 +72,13 @@ def prepare(directory, schema, stats): if not sensors: raise ValueError('No sensor observations') candidate = copy.deepcopy(schema) - history, targets, wheels = [], [], [(stamp, None) for stamp in boundaries] + history, targets = [], [] stats.update({'sensorFrames': len(sensors), 'dispatches': len(dispatches), - 'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][32:] for row in sensors], axis=0).tolist()}) + 'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][len(SENSORS):] for row in sensors], axis=0).tolist()}) for data in dispatches: command = data.get('command') or {} - request = requests.get(data.get('requestId')) - human = (data.get('source') == 'client' and request and data.get('commandId') in accepted - and data.get('commandId') not in failed) - stamp = data['ts'] - if command.get('type') == 'drive': - direct = command.get('driveDirect') or {} - pair = [direct.get('left'), direct.get('right')] - if human and all(isinstance(v, (int, float)) and math.isfinite(v) for v in pair): - wheels.append((stamp, np.clip(np.array(pair, dtype=np.float32), -metadata.get('wheelSpeedLimit', 500), metadata.get('wheelSpeedLimit', 500)) / 500)) - else: - wheels.append((stamp, None)) - elif command.get('type') in ('raw', 'reset', 'reboot', 'update', 'sensorStream'): - # Raw OI can change mode or motion; do not assume the last wheel - # command survives it. A later explicit human drive resolves intent. - wheels.append((stamp, None)) - boundaries.append(stamp) + if command.get('type') in ('reset', 'reboot', 'update', 'sensorStream'): + boundaries.append(data['ts']) encoded = encode_command(command, candidate, extend=True) if encoded is None: continue @@ -112,22 +98,21 @@ def prepare(directory, schema, stats): if not targets: raise ValueError('No accepted client operating commands') schema[:] = candidate - return metadata, sensors, sorted(wheels, key=lambda x: x[0]), sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries) + return metadata, sensors, sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries) def windows(directory, schema, cancelled=lambda: False, stats=None): directory = Path(directory) stats = stats if stats is not None else {} - metadata, sensors, wheels, history, targets, boundaries = prepare(directory, schema, stats) - wheel_times = [row[0] for row in wheels] - stats.update({'usableWindows': 0, 'movingWindows': 0, 'stoppedWindows': 0, - 'unknownWheelWindows': 0, 'staleSensorWindows': 0, 'boundaryWindows': 0, + metadata, sensors, history, targets, boundaries = prepare(directory, schema, stats) + stats.update({'usableWindows': 0, 'commandWindows': 0, 'stopWindows': 0, 'noCommandWindows': 0, + 'staleSensorWindows': 0, 'boundaryWindows': 0, 'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0}) sensor_times = [row[0] for row in sensors] target_times = [row[0] for row in targets] available = np.zeros(SLOTS, dtype=np.float32) for _, slot, _ in history: - available[slot] = float(schema[slot]['template']['type'] != 'drive') + available[slot] = 1 command_values = np.zeros((SLOTS, VALUES), dtype=np.float32) command_times = np.full(SLOTS, -np.inf) cursor = 0 @@ -164,8 +149,10 @@ def windows(directory, schema, cancelled=lambda: False, stats=None): last_sample = stamp while cursor < len(history) and history[cursor][0] < stamp: when, slot, values = history[cursor] - command_values[slot] = values - command_times[slot] = when + # Do not let drive predictions simply copy earlier human/model drive commands. + if schema[slot]['template']['type'] != 'drive': + command_values[slot] = values + command_times[slot] = when cursor += 1 sensor_index = bisect.bisect_right(sensor_times, stamp) - 1 if sensor_index < 0 or stamp - sensor_times[sensor_index] > 1000: @@ -188,12 +175,6 @@ def windows(directory, schema, cancelled=lambda: False, stats=None): if bisect.bisect_right(boundaries, stamp + 100) > bisect.bisect_left(boundaries, chosen[0][0]): stats['boundaryWindows'] += 1 continue - before = bisect.bisect_left(wheel_times, stamp) - 1 - after = bisect.bisect_right(wheel_times, stamp + 100) - 1 - if before < 0 or after < 0 or wheels[before][1] is None or wheels[after][1] is None: - stats['unknownWheelWindows'] += 1 - continue - wheel_target, previous_wheels = wheels[after][1], wheels[before][1] start = bisect.bisect_left(target_times, stamp) end = bisect.bisect_left(target_times, stamp + 100) actions = np.zeros(SLOTS, dtype=np.float32) @@ -202,13 +183,8 @@ def windows(directory, schema, cancelled=lambda: False, stats=None): families = set() for _, slot, vector in targets[start:end]: template = schema[slot]['template'] - family = template['type'] - if family == 'drive': - continue - if family == 'peripheral': - peripheral = template.get('peripheral') or {} - family += ':' + str(peripheral.get('id')) + ':' + str(peripheral.get('control')) - if ((actions[slot] and (not schema[slot]['fields'] or not np.array_equal(values[slot], vector))) + family = command_family(template) + if ((actions[slot] and (template['type'] in ('headlight', 'laser', 'horn', 'song', 'raw') or not np.array_equal(values[slot], vector))) or (not actions[slot] and family in families)): ambiguous = True break @@ -218,5 +194,6 @@ def windows(directory, schema, cancelled=lambda: False, stats=None): stats['ambiguousWindows'] += 1 continue stats['usableWindows'] += 1 - stats['movingWindows' if np.max(np.abs(wheel_target)) > .02 else 'stoppedWindows'] += 1 - yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available, wheel_target, previous_wheels + stats['commandWindows' if actions.any() else 'noCommandWindows'] += 1 + stats['stopWindows'] += int(any(actions[slot] and is_stop(descriptor) for slot, descriptor in enumerate(schema))) + yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available diff --git a/server/src/services/roverLearningService/workers/infer.py b/server/src/services/roverLearningService/workers/infer.py index 4d087d2e..0f2ad74f 100644 --- a/server/src/services/roverLearningService/workers/infer.py +++ b/server/src/services/roverLearningService/workers/infer.py @@ -14,7 +14,7 @@ import av import numpy as np import torch -from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, encode_command, sensor_vector +from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, select_slots, decode_command, encode_command, sensor_vector def main(): @@ -78,7 +78,7 @@ def main(): args.threshold = max(.05, min(.99, float(event['threshold']))) elif event['kind'] == 'sensor': sensors, sensor_time = event['data'], event['ts'] - elif event['kind'] == 'command': + elif event['kind'] == 'command' and event['data'].get('type') != 'drive': try: encoded = encode_command(event['data'], schema) if encoded: @@ -104,58 +104,30 @@ def main(): continue before = time.perf_counter() with torch.inference_mode(): - logits, numeric, wheel_output = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255, + logits, numeric = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255, torch.from_numpy(np.stack([row[2] for row in selected])).unsqueeze(0)) probabilities = logits[0].sigmoid().tolist() predictions = numeric[0].tolist() - wheel_speeds = [round(value * 500) for value in wheel_output[0].tolist()] if not all(math.isfinite(value) for value in probabilities): raise ValueError('Nonfinite model output') - families = {} - active = set() - proposals = [] - for slot, descriptor in enumerate(schema): - if descriptor['template']['type'] == 'drive': - continue - command = json.loads(json.dumps(descriptor['template'])) - family = command['type'] - if family == 'peripheral': - family += ':' + str(command['peripheral']['id']) + ':' + str(command['peripheral']['control']) - for index, field in enumerate(descriptor['fields']): - number = predictions[slot][index] - if not math.isfinite(number): - raise ValueError('Nonfinite control value') - number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale'] - target = command - for key in field['path'][:-1]: - target = target[key] - target[field['path'][-1]] = round(number) if field['integer'] else number - proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command, - 'reason': 'Below threshold'}) - if probabilities[slot] < args.threshold: - continue - active.add(slot) - discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw') - if family not in families or probabilities[slot] > families[family][0]: - families[family] = (probabilities[slot], command, slot, discrete) + winners = select_slots(probabilities, schema, args.threshold) + active = {slot for slot in range(len(schema)) if probabilities[slot] >= args.threshold} latched.intersection_update(active) - # Choose the winning action before latching, so a held toggle cannot - # expose a competing lower-confidence toggle on the following tick. - commands = [command for _, command, slot, discrete in families.values() - if not discrete or slot not in latched] - winners = {row[2]: row for row in families.values()} - for proposal in proposals: - slot = proposal['slot'] - if slot in active: - proposal['reason'] = ('Competing action' if slot not in winners else - 'Held discrete action' if winners[slot][3] and slot in latched else 'Proposed') - for _, _, slot, discrete in families.values(): - if discrete: + proposals, commands = [], [] + for slot, descriptor in enumerate(schema): + command = decode_command(descriptor, predictions[slot]) + discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw') + reason = ('Below threshold' if slot not in active else + 'Competing action' if slot not in winners else + 'Held discrete action' if discrete and slot in latched else 'Proposed') + proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command, 'reason': reason}) + if reason == 'Proposed': + commands.append(command) + if slot in winners and discrete: latched.add(slot) - commands.insert(0, {'type': 'drive', 'driveDirect': dict(zip(('left', 'right'), wheel_speeds))}) print(json.dumps({'kind': 'prediction', 'frameAt': stamp, 'sensorAt': sensor_time, 'latencyMs': (time.perf_counter() - before) * 1000, - 'wheelSpeeds': wheel_speeds, 'sensorPresent': sensor_vector(sensors)[32:].tolist(), + 'sensorPresent': sensor_vector(sensors)[len(SENSORS):].tolist(), 'commands': commands, 'proposals': proposals, 'threshold': args.threshold}, allow_nan=False), flush=True) diff --git a/server/src/services/roverLearningService/workers/policy.py b/server/src/services/roverLearningService/workers/policy.py index e73853b8..8a98dc8d 100644 --- a/server/src/services/roverLearningService/workers/policy.py +++ b/server/src/services/roverLearningService/workers/policy.py @@ -12,7 +12,7 @@ import numpy as np import torch from torch import nn -VERSION = 2 +VERSION = 3 SLOTS = 64 VALUES = 8 FRAMES = 4 @@ -26,7 +26,6 @@ SENSORS = [ ("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000), ("batteryCapacityMah", 4000), ("batteryTemperatureC", 50), ("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4), - ("wheelSpeedsMmPerSecond.left", 500), ("wheelSpeedsMmPerSecond.right", 500), ("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000), ("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000), ("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096), @@ -62,6 +61,10 @@ def describe_command(command): def visit(value, path=()): if isinstance(value, dict): return {key: visit(item, (*path, key)) for key, item in sorted(value.items())} + # Exact zeros preserve demonstrated releases/stops. Identity fields must + # also stay exact: regression must never invent a peripheral address. + if value == 0 or (path and path[-1] in ('id', 'control')): + return copy.deepcopy(value) if isinstance(value, (int, float)) and not isinstance(value, bool): if not math.isfinite(value) or len(values) >= VALUES: raise ValueError('Nonfinite command or too many numeric control fields') @@ -97,6 +100,44 @@ def encode_command(command, schema, extend=False): return slot, vector +def command_family(command): + if command['type'] == 'peripheral': + peripheral = command.get('peripheral') or {} + return ('peripheral', str(peripheral.get('id')), str(peripheral.get('control'))) + return (command['type'],) + + +def select_slots(scores, schema, threshold): + winners = {} + for slot, descriptor in enumerate(schema): + if scores[slot] < threshold: + continue + family = command_family(descriptor['template']) + if family not in winners or scores[slot] > scores[winners[family]]: + winners[family] = slot + return set(winners.values()) + + +def decode_command(descriptor, values): + command = copy.deepcopy(descriptor['template']) + for index, field in enumerate(descriptor['fields']): + number = float(values[index]) + if not math.isfinite(number): + raise ValueError('Nonfinite control value') + number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale'] + target = command + for key in field['path'][:-1]: + target = target[key] + target[field['path'][-1]] = round(number) if field['integer'] else number + return command + + +def is_stop(descriptor): + command = descriptor['template'] + direct = command.get('driveDirect') or {} + return command['type'] == 'drive' and direct.get('left') == 0 and direct.get('right') == 0 + + class Policy(nn.Module): def __init__(self): super().__init__() @@ -109,7 +150,6 @@ class Policy(nn.Module): nn.Linear(128 * 3 * 4, 128), nn.ReLU(), ) self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True) - self.wheels = nn.Linear(128, 2) self.actions = nn.Linear(128, SLOTS) self.values = nn.Linear(128, SLOTS * VALUES) @@ -118,17 +158,16 @@ class Policy(nn.Module): encoded = self.vision(images.reshape(-1, 3, HEIGHT, WIDTH)).reshape(batch, frames, -1) history, _ = self.history(torch.cat((encoded, state), dim=-1)) last = history[:, -1] - return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh(), self.wheels(last).tanh() + return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh() def specification(schema): - return {'version': VERSION, 'architecture': 'spatial-cnn-gru-wheels-v2', + return {'version': VERSION, 'architecture': 'spatial-cnn-gru-events-v3', 'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH, 'historyStrideMs': 300, 'decisionIntervalMs': 100, 'stateSize': STATE_SIZE, 'sensorFields': SENSORS, 'normalization': '2/pi * atan(value/scale); missing sensors have mask=0', 'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES, - 'commands': schema, 'actionSemantics': 'Continuous wheels at +100ms; accessories are events in the next 100ms', - 'wheelScaleMmPerSecond': 500, 'wheelOrder': ['left', 'right'], - 'wheelCommandExpiryMs': None, 'unknownWheelIntent': 'Excluded until next accepted human drive', + 'commands': schema, 'actionSemantics': 'All controls are command events in the next 100ms; no event preserves existing rover state', + 'driveCommandHistoryInput': False, 'zeroNumericValues': 'Exact template constants', 'timing': 'Server-receipt alignment; browser display latency unmeasured'} diff --git a/server/src/services/roverLearningService/workers/train.py b/server/src/services/roverLearningService/workers/train.py index 62072441..0f3f75ed 100644 --- a/server/src/services/roverLearningService/workers/train.py +++ b/server/src/services/roverLearningService/workers/train.py @@ -41,7 +41,7 @@ def main(): import torch from torch.nn import functional as functional from dataset import windows - from policy import Policy, VERSION, SLOTS, VALUES, specification + from policy import Policy, VERSION, SLOTS, VALUES, specification, is_stop, select_slots if args.check_dependencies: emit('dependencies', torch=str(torch.__version__), numpy=str(np.__version__), av=str(av.__version__), @@ -70,8 +70,9 @@ def main(): cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds'] model = Policy().cpu() optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001) - # A fresh training lineage prevents previously trained footage leaking into validation. - resume_path = root / 'training' / 'resume-v3.pt' + # The command-event policy has different inputs, targets and weights. Keep its + # optimizer lineage separate; reuse recordings with their unchanged partition. + resume_path = root / 'training' / 'resume-v4.pt' schema, steps, examples, last_published = [], 0, 0, 0 trained_rovers = set() control_profiles = [] @@ -107,7 +108,7 @@ def main(): candidate_schema = copy.deepcopy(schema) samples, seen = [], 0 stats = {} - buckets = {key: [] for key in ('stopped', 'forward', 'reverse', 'turn')} + buckets = {key: [] for key in ('stop', 'command', 'none')} counts = dict.fromkeys(buckets, 0) held_out = is_held_out(session_id) try: @@ -115,17 +116,16 @@ def main(): for sample in windows(directory, candidate_schema, cancelled, stats): seen += 1 # Keep evaluation naturally distributed. Training reserves space - # for ordinary turns/reversing so idle or straight motion cannot - # consume the entire sample budget. No scripted driving required. + # for recorded commands and releases so periods without commands + # cannot consume the entire sample budget. No scripted driving required. if held_out: reservoir, count, capacity = samples, seen, per_session else: - left, right = sample[5] - category = ('stopped' if max(abs(left), abs(right)) <= .02 else - 'turn' if abs(left - right) > .1 else - 'reverse' if left + right < 0 else 'forward') + category = ('stop' if any(sample[2][slot] and is_stop(descriptor) + for slot, descriptor in enumerate(candidate_schema)) else + 'command' if sample[2].any() else 'none') counts[category] += 1 - reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 4) + reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 3) if len(reservoir) < capacity: reservoir.append(sample) else: @@ -164,7 +164,7 @@ def main(): except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error: rejected[session_id] = str(error)[:500] emit('skipped', sessionId=session_id, reason=rejected[session_id]) - if len(pool) < config['minimumSamples'] or not any(np.max(np.abs(sample[5])) > .02 for sample in pool) or cancelled(): + if len(pool) < config['minimumSamples'] or not any(sample[2].any() for sample in pool) or cancelled(): emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool), diagnostics={'dataset': dataset_stats, 'decodeSeconds': time.monotonic() - decode_started}, reason='Stopped/time limit' if cancelled() else 'Waiting for enough training-partition samples with human actions') @@ -188,17 +188,16 @@ def main(): if cancelled() or steps - initial_steps >= config['maxStepsPerJob']: break batch = pool[offset:offset + config['batchSize']] - images, states, targets, values, available, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)] + images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*batch)] images = images.float().div_(255) optimizer.zero_grad(set_to_none=True) - action_logits, predictions, wheel_predictions = model(images, states) + action_logits, predictions = model(images, states) event_loss = functional.binary_cross_entropy_with_logits(action_logits, targets, pos_weight=positive_weight, reduction='none') event_loss = (event_loss * available).sum() / available.sum().clamp_min(1) mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1) value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1) - wheel_loss = functional.smooth_l1_loss(wheel_predictions, wheel_targets) - loss = wheel_loss + event_loss + value_loss + loss = event_loss + value_loss if not torch.isfinite(loss): raise ValueError('Nonfinite training loss; checkpoint not saved') loss.backward() @@ -209,7 +208,7 @@ def main(): losses.append(loss.item()) if steps % 10 == 0: emit('progress', steps=steps, examples=examples, loss=loss.item(), - losses={'wheels': wheel_loss.item(), 'events': event_loss.item(), 'accessoryValues': value_loss.item()}) + losses={'events': event_loss.item(), 'commandValues': value_loss.item()}) if cancelled() or steps - initial_steps >= config['maxStepsPerJob']: break if steps == initial_steps: @@ -224,60 +223,84 @@ def main(): if validation and not cancelled(): model.eval() true_positive = predicted_positive = actual_positive = 0 - value_error = value_count = 0 - evaluated = 0 - wheel_error = stopped_error = persistence_error = 0 - changed_error = changed_persistence = changed_count = moving_count = 0 + value_error = value_count = evaluated = 0 + stop_actual = stop_detected = false_stops = 0 + drive_actual = drive_detected = 0 + empty_windows = unwanted_windows = 0 + stop_slots = [slot for slot, descriptor in enumerate(schema) if is_stop(descriptor)] + drive_slots = [slot for slot, descriptor in enumerate(schema) + if descriptor['template']['type'] == 'drive' and not is_stop(descriptor)] + per_command = [{'slot': slot, 'command': descriptor['template'], 'actual': 0, + 'predicted': 0, 'matched': 0} for slot, descriptor in enumerate(schema)] with torch.inference_mode(): for offset in range(0, len(validation), config['batchSize']): if cancelled(): break batch = validation[offset:offset + config['batchSize']] - images, states, targets, values, available, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)] - logits, predictions, wheel_predictions = model(images.float() / 255, states) - predicted = (logits.sigmoid() >= job['actionThreshold']) & available.bool() - actual = targets.bool() & available.bool() + images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*batch)] + logits, predictions = model(images.float() / 255, states) + predicted = torch.zeros_like(targets, dtype=torch.bool) + # Use the same threshold and competing-command selection as live + # inference. Reservoir samples cannot assess temporal latching or + # closed-loop motion; report this explicitly rather than implying it. + for row, scores in enumerate(logits.sigmoid().tolist()): + for slot in select_slots(scores, schema, job['actionThreshold']): + predicted[row, slot] = True + actual = targets.bool() true_positive += (predicted & actual).sum().item() predicted_positive += predicted.sum().item() actual_positive += actual.sum().item() mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1) value_error += ((predictions - values).square() * mask).sum().item() value_count += mask.sum().item() - errors = (wheel_predictions - wheel_targets).abs().mean(dim=1) * 500 - persistence = (previous_wheels - wheel_targets).abs().mean(dim=1) * 500 - changed = (previous_wheels - wheel_targets).abs().amax(dim=1) > .02 - changed_error += errors[changed].sum().item() - changed_persistence += persistence[changed].sum().item() - changed_count += changed.sum().item() - moving_count += (wheel_targets.abs().amax(dim=1) > .02).sum().item() - wheel_error += errors.sum().item() - stopped_error += wheel_targets.abs().mean(dim=1).sum().item() * 500 - persistence_error += persistence.sum().item() + empty = ~actual.any(dim=1) + empty_windows += empty.sum().item() + unwanted_windows += (empty & predicted.any(dim=1)).sum().item() + if stop_slots: + stop_actual += actual[:, stop_slots].sum().item() + stop_detected += (actual[:, stop_slots] & predicted[:, stop_slots]).sum().item() + false_stops += (predicted[:, stop_slots] & ~actual[:, stop_slots]).sum().item() + if drive_slots: + drive_actual += actual[:, drive_slots].sum().item() + drive_detected += (actual[:, drive_slots] & predicted[:, drive_slots]).sum().item() + for item in per_command: + slot = item['slot'] + item['actual'] += actual[:, slot].sum().item() + item['predicted'] += predicted[:, slot].sum().item() + item['matched'] += (actual[:, slot] & predicted[:, slot]).sum().item() evaluated += len(batch) precision = true_positive / max(1, predicted_positive) recall = true_positive / max(1, actual_positive) mse = value_error / max(1, value_count) - wheel_mae = wheel_error / max(1, evaluated) - stopped_mae = stopped_error / max(1, evaluated) - persistence_mae = persistence_error / max(1, evaluated) - changed_mae = changed_error / max(1, changed_count) - changed_baseline = changed_persistence / max(1, changed_count) - # Check control changes separately: copying the previous speed can look - # excellent on long straight runs without learning to steer or stop. - wheels_pass = (evaluated == len(validation) and evaluated >= 128 and moving_count >= 32 - and changed_count >= 32 and wheel_mae < stopped_mae - and wheel_mae <= persistence_mae and changed_mae < changed_baseline) - accessories_pass = (predicted_positive == 0 if actual_positive == 0 else - actual_positive >= 32 and precision >= .5 and recall >= .5) - passed = wheels_pass and accessories_pass and (value_count == 0 or mse <= .1) - evaluation = {'status': 'passed imitation gate' if passed else 'imitation gate not met', + stop_recall = stop_detected / max(1, stop_actual) + held_out_sessions = sum(row.get('partition') == 'heldOut' and not row.get('skipReason') for row in dataset_stats) + gate_reasons = [] + for condition, reason in [ + (evaluated == len(validation), 'Evaluation interrupted'), + (held_out_sessions >= 2, 'Need two usable held-out recordings'), + (evaluated >= 128, 'Need 128 held-out windows'), + (actual_positive >= 32, 'Need 32 held-out command events'), + (stop_actual >= 8, 'Need eight held-out stop events'), + (drive_actual >= 16, 'Need 16 held-out movement commands'), + (drive_detected / max(1, drive_actual) >= .5, 'Movement command recall below 0.5'), + (precision >= .5, 'Command precision below 0.5'), + (recall >= .5, 'Command recall below 0.5'), + (stop_recall >= .5, 'Stop recall below 0.5'), + (value_count == 0 or mse <= .1, 'Command parameter error above 0.1'), + ]: + if not condition: + gate_reasons.append(reason) + evaluation = {'status': 'imitation gate not met' if gate_reasons else 'passed imitation gate', + 'method': 'Held-out 100ms command events; not a closed-loop rollout', + 'threshold': job['actionThreshold'], 'heldOutSessions': held_out_sessions, 'windows': evaluated, 'precision': precision, 'recall': recall, 'numericMse': mse, 'positiveActions': actual_positive, - 'wheelMaeMmPerSecond': wheel_mae, 'stoppedBaselineMaeMmPerSecond': stopped_mae, - 'previousSpeedBaselineMaeMmPerSecond': persistence_mae, - 'changedWheelMaeMmPerSecond': changed_mae, 'changedBaselineMaeMmPerSecond': changed_baseline, - 'changedWindows': changed_count, 'movingWindows': moving_count, - 'wheelsPassed': wheels_pass, 'accessoriesPassed': accessories_pass} + 'predictedActions': predicted_positive, 'falsePositiveActions': predicted_positive - true_positive, + 'stopEvents': stop_actual, 'missedStops': stop_actual - stop_detected, + 'stopRecall': stop_recall, 'falseStops': false_stops, + 'movementEvents': drive_actual, 'movementRecall': drive_detected / max(1, drive_actual), + 'noCommandWindows': empty_windows, 'unwantedCommandWindows': unwanted_windows, + 'commands': per_command, 'gateReasons': gate_reasons} evaluation_seconds = time.monotonic() - evaluation_started publication = {'distinctMinutes': unique_count / 600, 'newMinutes': (unique_count - published_windows) / 600, diff --git a/webui/package.json b/webui/package.json index 1a5a55dd..4b209d0e 100644 --- a/webui/package.json +++ b/webui/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "VITE_ROVERD_URL=http://192.168.0.86:8080 vite", + "dev": "VITE_ROVERD_URL=https://rover.otter.land vite", "build": "vite build", "lint": "eslint .", "test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js", diff --git a/webui/src/components/vip/VipRoverLearningCard.jsx b/webui/src/components/vip/VipRoverLearningCard.jsx index 96651053..f7e7060d 100644 --- a/webui/src/components/vip/VipRoverLearningCard.jsx +++ b/webui/src/components/vip/VipRoverLearningCard.jsx @@ -25,22 +25,20 @@ function Group({ title, children }) { ; } -const actionNames = { motors: 'Brushes / vacuum', servo: 'Camera tilt', headlight: 'Headlight', +const actionNames = { drive: 'Drive', motors: 'Brushes / vacuum', servo: 'Camera tilt', headlight: 'Headlight', laser: 'Laser', horn: 'Horn', peripheral: 'Accessory', song: 'Beeper', raw: 'Recorded control' }; -function WheelMeter({ label, speed }) { - const known = Number.isFinite(speed); - const value = known ? Math.max(-500, Math.min(500, speed)) : 0; - return
-
{label}{known ? `${value} mm/s` : 'Waiting'}
-
-
-
-
-
ReverseForward
-
; +function commandParameters(command) { + const fields = []; + const visit = (value, path) => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const [key, item] of Object.entries(value)) visit(item, path ? `${path}.${key}` : key); + } else { + fields.push(`${path}: ${typeof value === 'number' ? Number(value.toFixed(2)) : JSON.stringify(value)}`); + } + }; + for (const [key, value] of Object.entries(command || {})) if (key !== 'type') visit(value, key); + return fields.join(' · '); } export default function VipRoverLearningCard() { @@ -135,7 +133,7 @@ function LearningCard({ roverId }) {
} {session &&

{session.predictionStatus || 'Waiting for fresh video and sensors'}

-
- - -
{session.proposals?.map((proposal) => { - const submission = session.submissions?.find((item) => item.command.type === proposal.command.type + const submission = proposal.reason === 'Proposed' && session.submissions?.find((item) => item.command.type === proposal.command.type && (proposal.command.type !== 'peripheral' || (item.command.peripheral?.id === proposal.command.peripheral?.id && item.command.peripheral?.control === proposal.command.peripheral?.control))); const status = proposal.reason === 'Proposed' && submission ? submission.result : proposal.reason; return
{actionNames[proposal.command.type] || proposal.command.type}{proposal.command.type === 'peripheral' ? ` · ${proposal.command.peripheral?.id} / ${proposal.command.peripheral?.control}` : ''}{proposal.score.toFixed(2)}
-
+

{commandParameters(proposal.command)}

+
+
+
+

{status === 'issued' ? 'Submitted' : status}

+ {submission &&

Sent: {commandParameters(submission.command)}

}
; })}
@@ -202,8 +202,11 @@ function LearningCard({ roverId }) { {training?.status || 'Unavailable'} {display(training?.steps)} {display(training?.examples)} - {training?.losses ? `${training.losses.wheels.toFixed(4)} / ${training.losses.events.toFixed(4)} / ${training.losses.accessoryValues.toFixed(4)}` : 'Unavailable'} - {training?.evaluation?.wheelMaeMmPerSecond == null ? 'Unavailable' : `${training.evaluation.wheelMaeMmPerSecond.toFixed(1)} / ${training.evaluation.stoppedBaselineMaeMmPerSecond.toFixed(1)} / ${training.evaluation.previousSpeedBaselineMaeMmPerSecond.toFixed(1)} mm/s`} + {training?.losses ? `${training.losses.events?.toFixed(4) ?? 'Unavailable'} / ${training.losses.commandValues?.toFixed(4) ?? 'Unavailable'}` : 'Unavailable'} + {training?.evaluation?.numericMse?.toFixed(4) ?? 'Unavailable'} + {training?.evaluation?.stopEvents == null ? 'Unavailable' : `${training.evaluation.missedStops} / ${training.evaluation.stopEvents}`} + {training?.evaluation?.noCommandWindows == null ? 'Unavailable' : `${training.evaluation.unwantedCommandWindows} / ${training.evaluation.noCommandWindows}`} + {display(training?.evaluation?.heldOutSessions)} {training?.loss == null ? 'Unavailable' : training.loss.toFixed(4)} {training?.status === 'training' ? age(now, training.startedAt) : 'Not running'} {time(training?.nextAttemptAt)} @@ -220,6 +223,7 @@ function LearningCard({ roverId }) { {display(limits?.training.checkpointEverySteps)} {training?.error &&

{training.error}

} {training?.reason &&

{training.reason}

} + {training?.evaluation?.gateReasons?.map((reason) =>

{reason}

)} {training?.rejectionReasons?.map((reason) =>

{reason}

)}