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
@@ -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 };
@@ -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({
@@ -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) })),
@@ -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; }
@@ -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)}`;
@@ -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(),
@@ -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
@@ -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)
@@ -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'}
@@ -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,