This commit is contained in:
legop3
2026-09-28 23:22:15 -04:00
parent a395656056
commit ba71106544
13 changed files with 300 additions and 204 deletions
+93 -14
View File
@@ -2,18 +2,27 @@
## Resume here ## Resume here
Latest change: one ongoing learner, gated Latest/Previous checkpoints, and Latest change: format 3 command-event policy replaces format 2 continuous wheel
admin-created permanent snapshots replace automatic permanent publications. 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. Implemented: remove wheel-speed telemetry and previous drive-command history
Copyable diagnostics and main-card wheel/action displays with a session threshold from inputs (raw recordings unchanged); preserve exact numeric zeros; reuse
slider are implemented. Older model files remain stored but cannot run in this format. recordings with a fresh resume-v4.pt / schedule-v4.json lineage. Existing format 2
Recording, bounded dataset/training, permanent named models, live inference, snapshots remain stored and are visibly incompatible. One ongoing learner, gated
checked autonomous command submission, and the verified VIP card are coded. Latest/Previous, and admin-created permanent snapshots remain.
Human commands do not stop or pause autonomy (explicit latest user requirement).
Focused local checks passed; actual recording/training, live autonomous behavior, Main card shows decoded command parameters, score, applied threshold marker, and
concurrent server performance, and browser appearance remain unverified. submission results, including below-threshold proposals. Diagnostics reportVersion
The standalone CPU benchmark was run successfully on the actual server. 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 This document preserves the conversation's decisions across compactions and
usage cutoffs. Keep the checklist and handoff section current during authorized 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 Do not block further work or repeatedly ask them to do it. Preserve the unverified
status and have the reader reject unusable recordings automatically. status and have the reader reject unusable recordings automatically.
Latest increment: internal card gating, dock lead-in/tail capture, live prediction Latest increment: see the command-event policy handoff at the end. Earlier
diagnostics, ongoing retained-data sampling, and gated model publication are coded. format 2 wheel-specific design notes below are historical and superseded.
See the final handoff section for settings and the new training lineage.
Next work: verify the integrated feature against actual recordings and the real 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 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. windows: decoding/vocabulary/sample checks still apply in the worker.
Node syntax checks and diff whitespace checks passed; actual job selection on Node syntax checks and diff whitespace checks passed; actual job selection on
production recordings remains unverified. No new tests or background processes. 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.
@@ -1,5 +1,6 @@
// Match actuator configuration, not a rover's display name or changing telemetry. // Match actuator configuration, not a rover's display name or changing telemetry.
const { isDeepStrictEqual } = require('node:util'); const { isDeepStrictEqual } = require('node:util');
const POLICY_VERSION = 3;
function controlProfile(meta = {}) { function controlProfile(meta = {}) {
return Object.fromEntries(['cameraServo', 'peripherals', 'headlight', 'laser', 'roomba', 'platform'] return Object.fromEntries(['cameraServo', 'peripherals', 'headlight', 'laser', 'roomba', 'platform']
@@ -7,9 +8,9 @@ function controlProfile(meta = {}) {
} }
function compatible(model, record) { 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); const profile = controlProfile(record.meta);
return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(saved, profile)); return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(saved, profile));
} }
module.exports = { controlProfile, compatible }; module.exports = { POLICY_VERSION, controlProfile, compatible };
@@ -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.' }), enabled: boolean({ description: 'Enable human-session recording, background learning, and verified-user model control. Disabled starts no workers or storage.' }),
driving: strictObject({ driving: strictObject({
threads: integer({ minimum: 1, maximum: 16, description: 'CPU threads per active model controller.' }), 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.' }), 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'] }), }, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }),
recording: strictObject({ recording: strictObject({
@@ -1,5 +1,6 @@
// Export an explicit allowlist, never the raw socket/session/configuration object. // Export an explicit allowlist, never the raw socket/session/configuration object.
const { redact } = require('./errors'); const { redact } = require('./errors');
const { POLICY_VERSION } = require('./capabilities');
const os = require('node:os'); const os = require('node:os');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
@@ -7,7 +8,7 @@ const { createHash } = require('node:crypto');
const applicationVersion = require('../../../package.json').version; const applicationVersion = require('../../../package.json').version;
const hash = createHash('sha256'); const hash = createHash('sha256');
for (const file of ['workers/policy.py', 'workers/dataset.py', 'workers/train.py', 'workers/infer.py', 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))); hash.update(fs.readFileSync(path.join(__dirname, file)));
} }
const implementationSha256 = hash.digest('hex'); const implementationSha256 = hash.digest('hex');
@@ -29,8 +30,8 @@ function report(state, controller) {
const metrics = training.diagnostics || {}; const metrics = training.diagnostics || {};
const session = state.session; const session = state.session;
return { return {
reportVersion: 2, generatedAtUnixMs: state.updatedAt, reportVersion: 3, generatedAtUnixMs: state.updatedAt,
software: { applicationVersion, implementationSha256, policyVersion: 2, node: process.version, software: { applicationVersion, implementationSha256, policyVersion: POLICY_VERSION, node: process.version,
dependencies: metrics.dependencies || null }, dependencies: metrics.dependencies || null },
host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() }, host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() },
settings: state.limits, settings: state.limits,
@@ -52,7 +53,7 @@ function report(state, controller) {
controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null, controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null,
stopReason: redact(controller.stopReason), stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({ stopReason: redact(controller.stopReason), stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
...item, submissions: item.submissions.map((submission) => ({ type: submission.type, ...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, sensorFields: metrics.policy?.sensorFields || null,
timing: { alignment: 'server_receipt', browserVideoLatencyMs: null, wheelCommandExpiryMs: 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) })), events: state.activity.map(({ at, message, detail, error, stderr }) => ({ atUnixMs: at, message: redact(message), detail: redact(detail), error: error || null, stderr: redact(stderr) })),
@@ -127,21 +127,19 @@ function createDriving({ root, config, onChange, activity }) {
session.latencyMs = message.latencyMs; session.latencyMs = message.latencyMs;
session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : []; session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : [];
session.appliedThreshold = message.threshold; session.appliedThreshold = message.threshold;
session.wheelSpeeds = message.wheelSpeeds;
session.sensorPresent = message.sensorPresent; session.sensorPresent = message.sensorPresent;
session.submissions = []; session.submissions = [];
if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch'); 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. // Manual commands intentionally do not stop or pause this controller.
for (const command of message.commands) transmit(session, command); for (const command of message.commands) transmit(session, command);
// Bound report history independently of camera and UI frame rates. // Bound report history independently of camera and UI frame rates.
if (!session.history.length || Date.now() - session.history.at(-1).at >= 500) { 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, latencyMs: session.latencyMs, frameAgeMs: Date.now() - session.frameAt,
sensorAgeMs: Date.now() - session.sensorAt, threshold: session.appliedThreshold, 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 })), 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, submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result, command })) });
wheels: command.type === 'drive' ? command.driveDirect : undefined })) });
if (session.history.length > 60) session.history.shift(); if (session.history.length > 60) session.history.shift();
} }
} catch (error) { stop(roverId, error.message); break; } } catch (error) { stop(roverId, error.message); break; }
@@ -1,3 +1,4 @@
const { POLICY_VERSION } = require('./capabilities');
// Mutable aliases reference immutable checkpoints. All readers and retention share // Mutable aliases reference immutable checkpoints. All readers and retention share
// a queue so a model cannot disappear between resolving its alias and loading it. // a queue so a model cannot disappear between resolving its alias and loading it.
const fs = require('node:fs/promises'); 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'); if (!UUID.test(state.candidate || '')) throw new Error('No completed learner checkpoint yet');
const source = path.join(checkpointRoot(root), state.candidate); const source = path.join(checkpointRoot(root), state.candidate);
const original = await metadata(source); 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 id = randomUUID();
const words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']]; const words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']];
const name = `${words[0][randomInt(4)]}-${words[1][randomInt(4)]}-${id.slice(0, 8)}`; const name = `${words[0][randomInt(4)]}-${words[1][randomInt(4)]}-${id.slice(0, 8)}`;
@@ -33,7 +33,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
} }
} }
try { 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; ledger = saved.sessions;
state = { ...saved.progress, status: 'waiting' }; state = { ...saved.progress, status: 'waiting' };
if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule'); if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule');
@@ -46,7 +46,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
} }
async function saveLedger() { 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.writeFile(`${filename}.tmp`, JSON.stringify({ sessions: ledger, progress: state }));
await fs.rename(`${filename}.tmp`, filename); await fs.rename(`${filename}.tmp`, filename);
} }
@@ -83,7 +83,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
partitions[heldOut ? 'heldOut' : 'training'].push(id); partitions[heldOut ? 'heldOut' : 'training'].push(id);
} }
const trainingSessions = partitions.training.slice(0, partitions.heldOut.length ? 3 : 4); 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 selected = [...trainingSessions, ...heldOutSessions];
const selection = { const selection = {
atUnixMs: Date.now(), atUnixMs: Date.now(),
@@ -9,7 +9,7 @@ from pathlib import Path
import av import av
import numpy as np 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): def load_events(directory):
@@ -72,27 +72,13 @@ def prepare(directory, schema, stats):
if not sensors: if not sensors:
raise ValueError('No sensor observations') raise ValueError('No sensor observations')
candidate = copy.deepcopy(schema) candidate = copy.deepcopy(schema)
history, targets, wheels = [], [], [(stamp, None) for stamp in boundaries] history, targets = [], []
stats.update({'sensorFrames': len(sensors), 'dispatches': len(dispatches), 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: for data in dispatches:
command = data.get('command') or {} command = data.get('command') or {}
request = requests.get(data.get('requestId')) if command.get('type') in ('reset', 'reboot', 'update', 'sensorStream'):
human = (data.get('source') == 'client' and request and data.get('commandId') in accepted boundaries.append(data['ts'])
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)
encoded = encode_command(command, candidate, extend=True) encoded = encode_command(command, candidate, extend=True)
if encoded is None: if encoded is None:
continue continue
@@ -112,22 +98,21 @@ def prepare(directory, schema, stats):
if not targets: if not targets:
raise ValueError('No accepted client operating commands') raise ValueError('No accepted client operating commands')
schema[:] = candidate 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): def windows(directory, schema, cancelled=lambda: False, stats=None):
directory = Path(directory) directory = Path(directory)
stats = stats if stats is not None else {} stats = stats if stats is not None else {}
metadata, sensors, wheels, history, targets, boundaries = prepare(directory, schema, stats) metadata, sensors, history, targets, boundaries = prepare(directory, schema, stats)
wheel_times = [row[0] for row in wheels] stats.update({'usableWindows': 0, 'commandWindows': 0, 'stopWindows': 0, 'noCommandWindows': 0,
stats.update({'usableWindows': 0, 'movingWindows': 0, 'stoppedWindows': 0, 'staleSensorWindows': 0, 'boundaryWindows': 0,
'unknownWheelWindows': 0, 'staleSensorWindows': 0, 'boundaryWindows': 0,
'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0}) 'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0})
sensor_times = [row[0] for row in sensors] sensor_times = [row[0] for row in sensors]
target_times = [row[0] for row in targets] target_times = [row[0] for row in targets]
available = np.zeros(SLOTS, dtype=np.float32) available = np.zeros(SLOTS, dtype=np.float32)
for _, slot, _ in history: 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_values = np.zeros((SLOTS, VALUES), dtype=np.float32)
command_times = np.full(SLOTS, -np.inf) command_times = np.full(SLOTS, -np.inf)
cursor = 0 cursor = 0
@@ -164,8 +149,10 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
last_sample = stamp last_sample = stamp
while cursor < len(history) and history[cursor][0] < stamp: while cursor < len(history) and history[cursor][0] < stamp:
when, slot, values = history[cursor] when, slot, values = history[cursor]
command_values[slot] = values # Do not let drive predictions simply copy earlier human/model drive commands.
command_times[slot] = when if schema[slot]['template']['type'] != 'drive':
command_values[slot] = values
command_times[slot] = when
cursor += 1 cursor += 1
sensor_index = bisect.bisect_right(sensor_times, stamp) - 1 sensor_index = bisect.bisect_right(sensor_times, stamp) - 1
if sensor_index < 0 or stamp - sensor_times[sensor_index] > 1000: 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]): if bisect.bisect_right(boundaries, stamp + 100) > bisect.bisect_left(boundaries, chosen[0][0]):
stats['boundaryWindows'] += 1 stats['boundaryWindows'] += 1
continue 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) start = bisect.bisect_left(target_times, stamp)
end = bisect.bisect_left(target_times, stamp + 100) end = bisect.bisect_left(target_times, stamp + 100)
actions = np.zeros(SLOTS, dtype=np.float32) actions = np.zeros(SLOTS, dtype=np.float32)
@@ -202,13 +183,8 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
families = set() families = set()
for _, slot, vector in targets[start:end]: for _, slot, vector in targets[start:end]:
template = schema[slot]['template'] template = schema[slot]['template']
family = template['type'] family = command_family(template)
if family == 'drive': if ((actions[slot] and (template['type'] in ('headlight', 'laser', 'horn', 'song', 'raw') or not np.array_equal(values[slot], vector)))
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)))
or (not actions[slot] and family in families)): or (not actions[slot] and family in families)):
ambiguous = True ambiguous = True
break break
@@ -218,5 +194,6 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
stats['ambiguousWindows'] += 1 stats['ambiguousWindows'] += 1
continue continue
stats['usableWindows'] += 1 stats['usableWindows'] += 1
stats['movingWindows' if np.max(np.abs(wheel_target)) > .02 else 'stoppedWindows'] += 1 stats['commandWindows' if actions.any() else 'noCommandWindows'] += 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['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
@@ -14,7 +14,7 @@ import av
import numpy as np import numpy as np
import torch 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(): def main():
@@ -78,7 +78,7 @@ def main():
args.threshold = max(.05, min(.99, float(event['threshold']))) args.threshold = max(.05, min(.99, float(event['threshold'])))
elif event['kind'] == 'sensor': elif event['kind'] == 'sensor':
sensors, sensor_time = event['data'], event['ts'] sensors, sensor_time = event['data'], event['ts']
elif event['kind'] == 'command': elif event['kind'] == 'command' and event['data'].get('type') != 'drive':
try: try:
encoded = encode_command(event['data'], schema) encoded = encode_command(event['data'], schema)
if encoded: if encoded:
@@ -104,58 +104,30 @@ def main():
continue continue
before = time.perf_counter() before = time.perf_counter()
with torch.inference_mode(): 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)) torch.from_numpy(np.stack([row[2] for row in selected])).unsqueeze(0))
probabilities = logits[0].sigmoid().tolist() probabilities = logits[0].sigmoid().tolist()
predictions = numeric[0].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): if not all(math.isfinite(value) for value in probabilities):
raise ValueError('Nonfinite model output') raise ValueError('Nonfinite model output')
families = {} winners = select_slots(probabilities, schema, args.threshold)
active = set() active = {slot for slot in range(len(schema)) if probabilities[slot] >= args.threshold}
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)
latched.intersection_update(active) latched.intersection_update(active)
# Choose the winning action before latching, so a held toggle cannot proposals, commands = [], []
# expose a competing lower-confidence toggle on the following tick. for slot, descriptor in enumerate(schema):
commands = [command for _, command, slot, discrete in families.values() command = decode_command(descriptor, predictions[slot])
if not discrete or slot not in latched] discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw')
winners = {row[2]: row for row in families.values()} reason = ('Below threshold' if slot not in active else
for proposal in proposals: 'Competing action' if slot not in winners else
slot = proposal['slot'] 'Held discrete action' if discrete and slot in latched else 'Proposed')
if slot in active: proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command, 'reason': reason})
proposal['reason'] = ('Competing action' if slot not in winners else if reason == 'Proposed':
'Held discrete action' if winners[slot][3] and slot in latched else 'Proposed') commands.append(command)
for _, _, slot, discrete in families.values(): if slot in winners and discrete:
if discrete:
latched.add(slot) 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, print(json.dumps({'kind': 'prediction', 'frameAt': stamp, 'sensorAt': sensor_time,
'latencyMs': (time.perf_counter() - before) * 1000, '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) 'commands': commands, 'proposals': proposals, 'threshold': args.threshold}, allow_nan=False), flush=True)
@@ -12,7 +12,7 @@ import numpy as np
import torch import torch
from torch import nn from torch import nn
VERSION = 2 VERSION = 3
SLOTS = 64 SLOTS = 64
VALUES = 8 VALUES = 8
FRAMES = 4 FRAMES = 4
@@ -26,7 +26,6 @@ SENSORS = [
("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000), ("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000),
("batteryCapacityMah", 4000), ("batteryTemperatureC", 50), ("batteryCapacityMah", 4000), ("batteryTemperatureC", 50),
("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4), ("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4),
("wheelSpeedsMmPerSecond.left", 500), ("wheelSpeedsMmPerSecond.right", 500),
("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000), ("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000),
("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000), ("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000),
("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096), ("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096),
@@ -62,6 +61,10 @@ def describe_command(command):
def visit(value, path=()): def visit(value, path=()):
if isinstance(value, dict): if isinstance(value, dict):
return {key: visit(item, (*path, key)) for key, item in sorted(value.items())} 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 isinstance(value, (int, float)) and not isinstance(value, bool):
if not math.isfinite(value) or len(values) >= VALUES: if not math.isfinite(value) or len(values) >= VALUES:
raise ValueError('Nonfinite command or too many numeric control fields') raise ValueError('Nonfinite command or too many numeric control fields')
@@ -97,6 +100,44 @@ def encode_command(command, schema, extend=False):
return slot, vector 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): class Policy(nn.Module):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -109,7 +150,6 @@ class Policy(nn.Module):
nn.Linear(128 * 3 * 4, 128), nn.ReLU(), nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
) )
self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True) self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True)
self.wheels = nn.Linear(128, 2)
self.actions = nn.Linear(128, SLOTS) self.actions = nn.Linear(128, SLOTS)
self.values = nn.Linear(128, SLOTS * VALUES) 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) encoded = self.vision(images.reshape(-1, 3, HEIGHT, WIDTH)).reshape(batch, frames, -1)
history, _ = self.history(torch.cat((encoded, state), dim=-1)) history, _ = self.history(torch.cat((encoded, state), dim=-1))
last = history[:, -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): 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, 'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
'historyStrideMs': 300, 'decisionIntervalMs': 100, 'historyStrideMs': 300, 'decisionIntervalMs': 100,
'stateSize': STATE_SIZE, 'sensorFields': SENSORS, 'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
'normalization': '2/pi * atan(value/scale); missing sensors have mask=0', 'normalization': '2/pi * atan(value/scale); missing sensors have mask=0',
'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES, 'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES,
'commands': schema, 'actionSemantics': 'Continuous wheels at +100ms; accessories are events in the next 100ms', 'commands': schema, 'actionSemantics': 'All controls are command events in the next 100ms; no event preserves existing rover state',
'wheelScaleMmPerSecond': 500, 'wheelOrder': ['left', 'right'], 'driveCommandHistoryInput': False, 'zeroNumericValues': 'Exact template constants',
'wheelCommandExpiryMs': None, 'unknownWheelIntent': 'Excluded until next accepted human drive',
'timing': 'Server-receipt alignment; browser display latency unmeasured'} 'timing': 'Server-receipt alignment; browser display latency unmeasured'}
@@ -41,7 +41,7 @@ def main():
import torch import torch
from torch.nn import functional as functional from torch.nn import functional as functional
from dataset import windows 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: if args.check_dependencies:
emit('dependencies', torch=str(torch.__version__), numpy=str(np.__version__), av=str(av.__version__), 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'] cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds']
model = Policy().cpu() model = Policy().cpu()
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001) optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
# A fresh training lineage prevents previously trained footage leaking into validation. # The command-event policy has different inputs, targets and weights. Keep its
resume_path = root / 'training' / 'resume-v3.pt' # optimizer lineage separate; reuse recordings with their unchanged partition.
resume_path = root / 'training' / 'resume-v4.pt'
schema, steps, examples, last_published = [], 0, 0, 0 schema, steps, examples, last_published = [], 0, 0, 0
trained_rovers = set() trained_rovers = set()
control_profiles = [] control_profiles = []
@@ -107,7 +108,7 @@ def main():
candidate_schema = copy.deepcopy(schema) candidate_schema = copy.deepcopy(schema)
samples, seen = [], 0 samples, seen = [], 0
stats = {} stats = {}
buckets = {key: [] for key in ('stopped', 'forward', 'reverse', 'turn')} buckets = {key: [] for key in ('stop', 'command', 'none')}
counts = dict.fromkeys(buckets, 0) counts = dict.fromkeys(buckets, 0)
held_out = is_held_out(session_id) held_out = is_held_out(session_id)
try: try:
@@ -115,17 +116,16 @@ def main():
for sample in windows(directory, candidate_schema, cancelled, stats): for sample in windows(directory, candidate_schema, cancelled, stats):
seen += 1 seen += 1
# Keep evaluation naturally distributed. Training reserves space # Keep evaluation naturally distributed. Training reserves space
# for ordinary turns/reversing so idle or straight motion cannot # for recorded commands and releases so periods without commands
# consume the entire sample budget. No scripted driving required. # cannot consume the entire sample budget. No scripted driving required.
if held_out: if held_out:
reservoir, count, capacity = samples, seen, per_session reservoir, count, capacity = samples, seen, per_session
else: else:
left, right = sample[5] category = ('stop' if any(sample[2][slot] and is_stop(descriptor)
category = ('stopped' if max(abs(left), abs(right)) <= .02 else for slot, descriptor in enumerate(candidate_schema)) else
'turn' if abs(left - right) > .1 else 'command' if sample[2].any() else 'none')
'reverse' if left + right < 0 else 'forward')
counts[category] += 1 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: if len(reservoir) < capacity:
reservoir.append(sample) reservoir.append(sample)
else: else:
@@ -164,7 +164,7 @@ def main():
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error: except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
rejected[session_id] = str(error)[:500] rejected[session_id] = str(error)[:500]
emit('skipped', sessionId=session_id, reason=rejected[session_id]) 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), emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool),
diagnostics={'dataset': dataset_stats, 'decodeSeconds': time.monotonic() - decode_started}, 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') 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']: if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
break break
batch = pool[offset:offset + config['batchSize']] 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) images = images.float().div_(255)
optimizer.zero_grad(set_to_none=True) 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, event_loss = functional.binary_cross_entropy_with_logits(action_logits, targets,
pos_weight=positive_weight, reduction='none') pos_weight=positive_weight, reduction='none')
event_loss = (event_loss * available).sum() / available.sum().clamp_min(1) event_loss = (event_loss * available).sum() / available.sum().clamp_min(1)
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1) mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1) value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
wheel_loss = functional.smooth_l1_loss(wheel_predictions, wheel_targets) loss = event_loss + value_loss
loss = wheel_loss + event_loss + value_loss
if not torch.isfinite(loss): if not torch.isfinite(loss):
raise ValueError('Nonfinite training loss; checkpoint not saved') raise ValueError('Nonfinite training loss; checkpoint not saved')
loss.backward() loss.backward()
@@ -209,7 +208,7 @@ def main():
losses.append(loss.item()) losses.append(loss.item())
if steps % 10 == 0: if steps % 10 == 0:
emit('progress', steps=steps, examples=examples, loss=loss.item(), 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']: if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
break break
if steps == initial_steps: if steps == initial_steps:
@@ -224,60 +223,84 @@ def main():
if validation and not cancelled(): if validation and not cancelled():
model.eval() model.eval()
true_positive = predicted_positive = actual_positive = 0 true_positive = predicted_positive = actual_positive = 0
value_error = value_count = 0 value_error = value_count = evaluated = 0
evaluated = 0 stop_actual = stop_detected = false_stops = 0
wheel_error = stopped_error = persistence_error = 0 drive_actual = drive_detected = 0
changed_error = changed_persistence = changed_count = moving_count = 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(): with torch.inference_mode():
for offset in range(0, len(validation), config['batchSize']): for offset in range(0, len(validation), config['batchSize']):
if cancelled(): if cancelled():
break break
batch = validation[offset:offset + config['batchSize']] 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)] images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
logits, predictions, wheel_predictions = model(images.float() / 255, states) logits, predictions = model(images.float() / 255, states)
predicted = (logits.sigmoid() >= job['actionThreshold']) & available.bool() predicted = torch.zeros_like(targets, dtype=torch.bool)
actual = targets.bool() & available.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() true_positive += (predicted & actual).sum().item()
predicted_positive += predicted.sum().item() predicted_positive += predicted.sum().item()
actual_positive += actual.sum().item() actual_positive += actual.sum().item()
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1) mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
value_error += ((predictions - values).square() * mask).sum().item() value_error += ((predictions - values).square() * mask).sum().item()
value_count += mask.sum().item() value_count += mask.sum().item()
errors = (wheel_predictions - wheel_targets).abs().mean(dim=1) * 500 empty = ~actual.any(dim=1)
persistence = (previous_wheels - wheel_targets).abs().mean(dim=1) * 500 empty_windows += empty.sum().item()
changed = (previous_wheels - wheel_targets).abs().amax(dim=1) > .02 unwanted_windows += (empty & predicted.any(dim=1)).sum().item()
changed_error += errors[changed].sum().item() if stop_slots:
changed_persistence += persistence[changed].sum().item() stop_actual += actual[:, stop_slots].sum().item()
changed_count += changed.sum().item() stop_detected += (actual[:, stop_slots] & predicted[:, stop_slots]).sum().item()
moving_count += (wheel_targets.abs().amax(dim=1) > .02).sum().item() false_stops += (predicted[:, stop_slots] & ~actual[:, stop_slots]).sum().item()
wheel_error += errors.sum().item() if drive_slots:
stopped_error += wheel_targets.abs().mean(dim=1).sum().item() * 500 drive_actual += actual[:, drive_slots].sum().item()
persistence_error += persistence.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) evaluated += len(batch)
precision = true_positive / max(1, predicted_positive) precision = true_positive / max(1, predicted_positive)
recall = true_positive / max(1, actual_positive) recall = true_positive / max(1, actual_positive)
mse = value_error / max(1, value_count) mse = value_error / max(1, value_count)
wheel_mae = wheel_error / max(1, evaluated) stop_recall = stop_detected / max(1, stop_actual)
stopped_mae = stopped_error / max(1, evaluated) held_out_sessions = sum(row.get('partition') == 'heldOut' and not row.get('skipReason') for row in dataset_stats)
persistence_mae = persistence_error / max(1, evaluated) gate_reasons = []
changed_mae = changed_error / max(1, changed_count) for condition, reason in [
changed_baseline = changed_persistence / max(1, changed_count) (evaluated == len(validation), 'Evaluation interrupted'),
# Check control changes separately: copying the previous speed can look (held_out_sessions >= 2, 'Need two usable held-out recordings'),
# excellent on long straight runs without learning to steer or stop. (evaluated >= 128, 'Need 128 held-out windows'),
wheels_pass = (evaluated == len(validation) and evaluated >= 128 and moving_count >= 32 (actual_positive >= 32, 'Need 32 held-out command events'),
and changed_count >= 32 and wheel_mae < stopped_mae (stop_actual >= 8, 'Need eight held-out stop events'),
and wheel_mae <= persistence_mae and changed_mae < changed_baseline) (drive_actual >= 16, 'Need 16 held-out movement commands'),
accessories_pass = (predicted_positive == 0 if actual_positive == 0 else (drive_detected / max(1, drive_actual) >= .5, 'Movement command recall below 0.5'),
actual_positive >= 32 and precision >= .5 and recall >= .5) (precision >= .5, 'Command precision below 0.5'),
passed = wheels_pass and accessories_pass and (value_count == 0 or mse <= .1) (recall >= .5, 'Command recall below 0.5'),
evaluation = {'status': 'passed imitation gate' if passed else 'imitation gate not met', (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, 'windows': evaluated, 'precision': precision, 'recall': recall,
'numericMse': mse, 'positiveActions': actual_positive, 'numericMse': mse, 'positiveActions': actual_positive,
'wheelMaeMmPerSecond': wheel_mae, 'stoppedBaselineMaeMmPerSecond': stopped_mae, 'predictedActions': predicted_positive, 'falsePositiveActions': predicted_positive - true_positive,
'previousSpeedBaselineMaeMmPerSecond': persistence_mae, 'stopEvents': stop_actual, 'missedStops': stop_actual - stop_detected,
'changedWheelMaeMmPerSecond': changed_mae, 'changedBaselineMaeMmPerSecond': changed_baseline, 'stopRecall': stop_recall, 'falseStops': false_stops,
'changedWindows': changed_count, 'movingWindows': moving_count, 'movementEvents': drive_actual, 'movementRecall': drive_detected / max(1, drive_actual),
'wheelsPassed': wheels_pass, 'accessoriesPassed': accessories_pass} 'noCommandWindows': empty_windows, 'unwantedCommandWindows': unwanted_windows,
'commands': per_command, 'gateReasons': gate_reasons}
evaluation_seconds = time.monotonic() - evaluation_started evaluation_seconds = time.monotonic() - evaluation_started
publication = {'distinctMinutes': unique_count / 600, publication = {'distinctMinutes': unique_count / 600,
'newMinutes': (unique_count - published_windows) / 600, 'newMinutes': (unique_count - published_windows) / 600,
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "VITE_ROVERD_URL=http://192.168.0.86:8080 vite", "dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js", "test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
@@ -25,22 +25,20 @@ function Group({ title, children }) {
</section>; </section>;
} }
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' }; laser: 'Laser', horn: 'Horn', peripheral: 'Accessory', song: 'Beeper', raw: 'Recorded control' };
function WheelMeter({ label, speed }) { function commandParameters(command) {
const known = Number.isFinite(speed); const fields = [];
const value = known ? Math.max(-500, Math.min(500, speed)) : 0; const visit = (value, path) => {
return <div className="surface-muted min-w-0 space-y-1"> if (value && typeof value === 'object' && !Array.isArray(value)) {
<div className="flex justify-between gap-2 text-xs"><span>{label}</span><span>{known ? `${value} mm/s` : 'Waiting'}</span></div> for (const [key, item] of Object.entries(value)) visit(item, path ? `${path}.${key}` : key);
<div role="meter" aria-label={label} aria-valuemin={-500} aria-valuemax={500} aria-valuenow={value} } else {
aria-valuetext={known ? `${value} millimeters per second` : 'Unavailable'} className="relative h-3 overflow-hidden rounded bg-slate-800"> fields.push(`${path}: ${typeof value === 'number' ? Number(value.toFixed(2)) : JSON.stringify(value)}`);
<div className={`absolute inset-y-0 rounded ${value < 0 ? 'bg-amber-400' : 'bg-sky-400'}`} }
style={{ left: `${value < 0 ? 50 + value / 10 : 50}%`, width: `${Math.abs(value) / 10}%` }} /> };
<div className="absolute inset-y-0 left-1/2 w-px bg-slate-400" /> for (const [key, value] of Object.entries(command || {})) if (key !== 'type') visit(value, key);
</div> return fields.join(' · ');
<div className="flex justify-between text-xs text-slate-500"><span>Reverse</span><span>Forward</span></div>
</div>;
} }
export default function VipRoverLearningCard() { export default function VipRoverLearningCard() {
@@ -135,7 +133,7 @@ function LearningCard({ roverId }) {
<div className="flex flex-wrap items-center justify-center gap-1"> <div className="flex flex-wrap items-center justify-center gap-1">
<select aria-label="Rover model" className={fieldClass} value={chosen?.id || ''} onChange={(event) => setSelectedId(event.target.value)} disabled={working || !models.length}> <select aria-label="Rover model" className={fieldClass} value={chosen?.id || ''} onChange={(event) => setSelectedId(event.target.value)} disabled={working || !models.length}>
{!models.length && <option value="">No published models yet</option>} {!models.length && <option value="">No published models yet</option>}
{models.map((model) => <option key={model.id} value={model.id}>{favorites.includes(model.id) ? '★ ' : ''}{model.name}{model.experimental ? ' (experimental)' : ''}{model.compatible ? '' : model.formatVersion !== 2 ? ' (older model format)' : ' (incompatible)'}</option>)} {models.map((model) => <option key={model.id} value={model.id}>{favorites.includes(model.id) ? '★ ' : ''}{model.name}{model.experimental ? ' (experimental)' : ''}{model.compatible ? '' : model.formatVersion !== 3 ? ' (older model format)' : ' (incompatible)'}</option>)}
</select> </select>
<button type="button" className="button-dark text-sm disabled:opacity-50" aria-pressed={Boolean(chosen && favorites.includes(chosen.id))} disabled={!chosen} onClick={favorite}> <button type="button" className="button-dark text-sm disabled:opacity-50" aria-pressed={Boolean(chosen && favorites.includes(chosen.id))} disabled={!chosen} onClick={favorite}>
{chosen && favorites.includes(chosen.id) ? '★ Favorite' : '☆ Favorite'} {chosen && favorites.includes(chosen.id) ? '★ Favorite' : '☆ Favorite'}
@@ -150,12 +148,8 @@ function LearningCard({ roverId }) {
</div>} </div>}
{session && <section className="space-y-2" aria-label="Live model actions"> {session && <section className="space-y-2" aria-label="Live model actions">
<p className="text-center text-xs text-sky-300">{session.predictionStatus || 'Waiting for fresh video and sensors'}</p> <p className="text-center text-xs text-sky-300">{session.predictionStatus || 'Waiting for fresh video and sensors'}</p>
<div className="grid grid-cols-2 gap-2">
<WheelMeter label="Predicted left wheel" speed={session.wheelSpeeds?.[0]} />
<WheelMeter label="Predicted right wheel" speed={session.wheelSpeeds?.[1]} />
</div>
<label className="surface-muted block space-y-1 text-xs"> <label className="surface-muted block space-y-1 text-xs">
<span className="flex justify-between gap-2"><span>Accessory action threshold</span><span>{(thresholdDraft ?? session.threshold ?? .7).toFixed(2)}</span></span> <span className="flex justify-between gap-2"><span>Command threshold</span><span>{(thresholdDraft ?? session.threshold ?? .7).toFixed(2)}</span></span>
<input type="range" min="0.05" max="0.99" step="0.01" className="w-full accent-sky-400" <input type="range" min="0.05" max="0.99" step="0.01" className="w-full accent-sky-400"
value={thresholdDraft ?? session.threshold ?? .7} disabled={working || !state?.control?.canAdjust} value={thresholdDraft ?? session.threshold ?? .7} disabled={working || !state?.control?.canAdjust}
onChange={(event) => setThresholdDraft(Number(event.target.value))} onChange={(event) => setThresholdDraft(Number(event.target.value))}
@@ -163,14 +157,20 @@ function LearningCard({ roverId }) {
</label> </label>
<div className="grid max-h-52 grid-cols-1 gap-1 overflow-y-auto @[28rem]:grid-cols-2"> <div className="grid max-h-52 grid-cols-1 gap-1 overflow-y-auto @[28rem]:grid-cols-2">
{session.proposals?.map((proposal) => { {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 && (proposal.command.type !== 'peripheral' || (item.command.peripheral?.id === proposal.command.peripheral?.id
&& item.command.peripheral?.control === proposal.command.peripheral?.control))); && item.command.peripheral?.control === proposal.command.peripheral?.control)));
const status = proposal.reason === 'Proposed' && submission ? submission.result : proposal.reason; const status = proposal.reason === 'Proposed' && submission ? submission.result : proposal.reason;
return <div key={proposal.slot} className="surface-muted space-y-1 text-xs"> return <div key={proposal.slot} className="surface-muted space-y-1 text-xs">
<div className="flex justify-between gap-2"><span>{actionNames[proposal.command.type] || proposal.command.type}{proposal.command.type === 'peripheral' ? ` · ${proposal.command.peripheral?.id} / ${proposal.command.peripheral?.control}` : ''}</span><span>{proposal.score.toFixed(2)}</span></div> <div className="flex justify-between gap-2"><span>{actionNames[proposal.command.type] || proposal.command.type}{proposal.command.type === 'peripheral' ? ` · ${proposal.command.peripheral?.id} / ${proposal.command.peripheral?.control}` : ''}</span><span>{proposal.score.toFixed(2)}</span></div>
<div className="h-1.5 overflow-hidden rounded bg-slate-800"><div className={status === 'issued' ? 'h-full bg-emerald-400' : 'h-full bg-slate-500'} style={{ width: `${proposal.score * 100}%` }} /></div> <p className="break-words text-slate-300">{commandParameters(proposal.command)}</p>
<div role="meter" aria-label={`${actionNames[proposal.command.type] || proposal.command.type} score`}
aria-valuemin={0} aria-valuemax={1} aria-valuenow={proposal.score} className="relative h-2 overflow-hidden rounded bg-slate-800">
<div className={status === 'issued' ? 'h-full bg-emerald-400' : 'h-full bg-sky-500'} style={{ width: `${proposal.score * 100}%` }} />
<div className="absolute inset-y-0 w-px bg-white" style={{ left: `${(session.appliedThreshold ?? session.threshold ?? .7) * 100}%` }} />
</div>
<p className={status === 'issued' ? 'text-emerald-300' : 'text-slate-400'}>{status === 'issued' ? 'Submitted' : status}</p> <p className={status === 'issued' ? 'text-emerald-300' : 'text-slate-400'}>{status === 'issued' ? 'Submitted' : status}</p>
{submission && <p className="break-words text-slate-400">Sent: {commandParameters(submission.command)}</p>}
</div>; </div>;
})} })}
</div> </div>
@@ -202,8 +202,11 @@ function LearningCard({ roverId }) {
<Row label="Status">{training?.status || 'Unavailable'}</Row> <Row label="Status">{training?.status || 'Unavailable'}</Row>
<Row label="Steps">{display(training?.steps)}</Row> <Row label="Steps">{display(training?.steps)}</Row>
<Row label="Examples processed">{display(training?.examples)}</Row> <Row label="Examples processed">{display(training?.examples)}</Row>
<Row label="Wheel / event / accessory losses">{training?.losses ? `${training.losses.wheels.toFixed(4)} / ${training.losses.events.toFixed(4)} / ${training.losses.accessoryValues.toFixed(4)}` : 'Unavailable'}</Row> <Row label="Event / parameter losses">{training?.losses ? `${training.losses.events?.toFixed(4) ?? 'Unavailable'} / ${training.losses.commandValues?.toFixed(4) ?? 'Unavailable'}` : 'Unavailable'}</Row>
<Row label="Wheel error / stopped baseline / previous speed baseline">{training?.evaluation?.wheelMaeMmPerSecond == null ? 'Unavailable' : `${training.evaluation.wheelMaeMmPerSecond.toFixed(1)} / ${training.evaluation.stoppedBaselineMaeMmPerSecond.toFixed(1)} / ${training.evaluation.previousSpeedBaselineMaeMmPerSecond.toFixed(1)} mm/s`}</Row> <Row label="Parameter error">{training?.evaluation?.numericMse?.toFixed(4) ?? 'Unavailable'}</Row>
<Row label="Missed / total stop events">{training?.evaluation?.stopEvents == null ? 'Unavailable' : `${training.evaluation.missedStops} / ${training.evaluation.stopEvents}`}</Row>
<Row label="Unwanted / no-command windows">{training?.evaluation?.noCommandWindows == null ? 'Unavailable' : `${training.evaluation.unwantedCommandWindows} / ${training.evaluation.noCommandWindows}`}</Row>
<Row label="Usable held-out recordings">{display(training?.evaluation?.heldOutSessions)}</Row>
<Row label="Training loss">{training?.loss == null ? 'Unavailable' : training.loss.toFixed(4)}</Row> <Row label="Training loss">{training?.loss == null ? 'Unavailable' : training.loss.toFixed(4)}</Row>
<Row label="Current job age">{training?.status === 'training' ? age(now, training.startedAt) : 'Not running'}</Row> <Row label="Current job age">{training?.status === 'training' ? age(now, training.startedAt) : 'Not running'}</Row>
<Row label="Next attempt">{time(training?.nextAttemptAt)}</Row> <Row label="Next attempt">{time(training?.nextAttemptAt)}</Row>
@@ -220,6 +223,7 @@ function LearningCard({ roverId }) {
<Row label="Steps between Latest updates">{display(limits?.training.checkpointEverySteps)}</Row> <Row label="Steps between Latest updates">{display(limits?.training.checkpointEverySteps)}</Row>
</dl>{training?.error && <p className="break-words text-amber-300">{training.error}</p>} </dl>{training?.error && <p className="break-words text-amber-300">{training.error}</p>}
{training?.reason && <p>{training.reason}</p>} {training?.reason && <p>{training.reason}</p>}
{training?.evaluation?.gateReasons?.map((reason) => <p key={reason}>{reason}</p>)}
{training?.rejectionReasons?.map((reason) => <p className="break-words text-amber-300" key={reason}>{reason}</p>)} {training?.rejectionReasons?.map((reason) => <p className="break-words text-amber-300" key={reason}>{reason}</p>)}
</Group> </Group>
<Group title="Selected model"><dl> <Group title="Selected model"><dl>