fix: retain planning feedback and add acceptance probes

This commit is contained in:
2026-09-22 00:47:11 +08:00
parent f9d8feb6f0
commit 91bcd92d6b
12 changed files with 871 additions and 10 deletions
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Monitor one Linux process for a real eight-hour duration/liveness qualification.
Examples (never selects robot endpoints):
python tools/native_soak.py --pid 1234 --output /tmp/soak-unique \
--progress-file /tmp/completed.json --journal /tmp/goals.log
python tools/native_soak.py --output /tmp/qualification --duration-seconds 30 \
--command python -u workload.py
The workload must atomically replace progress JSON {"completed": N} after actual
business work completes. A timer/heartbeat is NOT a valid business progress metric.
Counter reset, stale progress, process exit/replacement and sampling failure fail.
An attached process is never signaled. A spawned command runs once (no shell or
restart); after monitoring it receives SIGTERM, then SIGKILL after five seconds.
Only that direct child is owned/monitored: use a foreground executor, not a launcher
or shell with detached children. Logs/resources of descendants are not aggregated.
Exit 0: observed >=8h and progressing; NOT full DR acceptance. Resource growth and
workload validity still need review. Exit 2: short/missing-metric/interrupted run.
Exit 1: observed failure. Missing final summary means interrupted/incomplete, never
pass. No simulated/ROS clock, injectable clock or shortened acceptance threshold.
"""
import argparse
from datetime import datetime, timezone
import json
import math
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
EIGHT_HOURS = 8 * 60 * 60
def verdict(elapsed, progress_observed, failure=None, interrupted=False, sampling_gap=False):
qualified = (elapsed >= EIGHT_HOURS and progress_observed and not failure
and not interrupted and not sampling_gap)
return dict(status='failed' if failure else 'duration_liveness_pass' if qualified else 'qualification_incomplete',
eight_hour_pass=qualified, no_deadlock_claim=False,
duration_liveness_pass=qualified, full_dr_acceptance=False,
resource_growth_review_required=True,
scope='Same process survival and declared business progress only; not proof of physical safety or absence of all deadlocks')
def positive(text):
value = float(text)
if not math.isfinite(value) or value <= 0:
raise argparse.ArgumentTypeError('must be finite and positive')
return value
def process_sample(pid):
base = Path('/proc') / str(pid)
raw = (base / 'stat').read_text()
fields = raw[raw.rfind(')') + 2:].split()
if fields[0] in ('Z', 'X', 'x'):
raise RuntimeError('process exited (zombie/dead)')
status = dict(line.split(':', 1) for line in (base / 'status').read_text().splitlines() if ':' in line)
result = dict(pid=pid, start_ticks=int(fields[19]),
rss_bytes=int(status['VmRSS'].split()[0]) * 1024,
threads=int(status['Threads']), fd_count=len(list((base / 'fd').iterdir())),
cpu_seconds=(int(fields[11]) + int(fields[12])) / os.sysconf('SC_CLK_TCK'))
# Detect exit/replacement during multi-file sampling as well as between samples.
again = (base / 'stat').read_text().rsplit(')', 1)[1].split()
if int(again[19]) != result['start_ticks'] or again[0] in ('Z', 'X', 'x'):
raise RuntimeError('process identity changed during sampling')
return result
def atomic_json(path, value):
temporary = path.with_suffix('.tmp')
with temporary.open('w') as stream:
json.dump(value, stream, indent=2, allow_nan=False)
stream.write('\n')
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument('--pid', type=int)
source.add_argument('--command', nargs=argparse.REMAINDER, help='Foreground argv; must be last option; no shell')
parser.add_argument('--output', type=Path, required=True, help='New evidence directory; existing paths refused')
parser.add_argument('--duration-seconds', type=positive, default=EIGHT_HOURS)
parser.add_argument('--sample-interval-seconds', type=positive, default=5.)
parser.add_argument('--max-progress-gap-seconds', type=positive, default=300.)
parser.add_argument('--progress-file', type=Path)
parser.add_argument('--journal', type=Path, action='append', default=[])
args = parser.parse_args(argv)
if not sys.platform.startswith('linux'):
parser.error('Linux /proc required')
if args.pid is not None and args.pid <= 0:
parser.error('--pid must be positive')
if args.command == []:
parser.error('--command requires argv')
if args.progress_file and args.sample_interval_seconds >= args.max_progress_gap_seconds:
parser.error('sample interval must be shorter than progress gap')
args.output.mkdir(parents=True, exist_ok=False)
summary_path = args.output / 'summary.json'
report = dict(schema_version=1, started_utc=datetime.now(timezone.utc).isoformat(),
duration_requested_seconds=args.duration_seconds, acceptance_duration_seconds=EIGHT_HOURS,
clock='time.monotonic (host Linux; excludes suspend)',
mode='attach' if args.pid else 'spawn', command=args.command,
progress_file=str(args.progress_file) if args.progress_file else None,
progress_contract='Atomic JSON completed counter incremented by actual workload completion',
sample_interval_seconds=args.sample_interval_seconds,
max_progress_gap_seconds=args.max_progress_gap_seconds,
boot_id=Path('/proc/sys/kernel/random/boot_id').read_text().strip(),
status='running', eight_hour_pass=False, full_dr_acceptance=False,
resource_growth_review_required=True)
atomic_json(summary_path, report)
child = None
interrupted = False
failure = None
gap = False
start = time.monotonic()
last_progress = start
previous_counter = None
progress_observed = False
previous = None
identity = None
sample_count = 0
peaks = dict(rss_bytes=0, threads=0, fd_count=0)
old_handlers = {}
def request_stop(signum, frame):
nonlocal interrupted
interrupted = True
try:
for sig in (signal.SIGINT, signal.SIGTERM):
old_handlers[sig] = signal.signal(sig, request_stop)
with (args.output / 'process.log').open('w') as process_log, (args.output / 'samples.jsonl').open('w') as samples:
if args.command:
child = subprocess.Popen(args.command, stdin=subprocess.DEVNULL, stdout=process_log, stderr=subprocess.STDOUT)
pid = child.pid if child else args.pid
report['pid'] = pid
start = time.monotonic()
last_progress = start
while not interrupted:
if child and child.poll() is not None:
raise RuntimeError('process exited before observation completed: ' + str(child.returncode))
sample = process_sample(pid)
now = time.monotonic()
sample['elapsed_seconds'] = now - start
if identity is None:
identity = sample['start_ticks']
report['start_ticks'] = identity
if sample['start_ticks'] != identity:
raise RuntimeError('process identity changed (PID reused)')
sample['cpu_percent'] = None
if previous:
delta = sample['elapsed_seconds'] - previous['elapsed_seconds']
sample['cpu_percent'] = 100 * (sample['cpu_seconds'] - previous['cpu_seconds']) / delta
if delta > max(5., args.sample_interval_seconds * 3):
gap = True
sample['journal_bytes'] = {str(p.resolve()): p.stat().st_size for p in args.journal}
sample['completed'] = None
if args.progress_file:
# Startup and subsequent progress share the same strict bound.
# Late evidence cannot retroactively erase an observed gap.
if now - last_progress > args.max_progress_gap_seconds:
raise RuntimeError('progress deadline exceeded; process liveness is insufficient')
try:
value = json.loads(args.progress_file.read_text())['completed']
except FileNotFoundError:
if previous_counter is not None:
raise RuntimeError('progress metric disappeared')
value = None # Allow initial workload startup, bounded by the same deadline.
if value is not None:
if type(value) is not int or value < 0:
raise RuntimeError('progress counter must be a nonnegative integer')
if previous_counter is not None:
if value < previous_counter:
raise RuntimeError('progress counter regressed')
if value > previous_counter:
progress_observed = True
last_progress = now
previous_counter = value
sample['completed'] = value
samples.write(json.dumps(sample, allow_nan=False) + '\n')
samples.flush()
os.fsync(samples.fileno())
sample_count += 1
for name in peaks:
peaks[name] = max(peaks[name], sample[name])
previous = sample
report.update(elapsed_seconds=now - start, samples=sample_count, resource_peaks=peaks,
progress_observed=progress_observed, last_completed=previous_counter)
atomic_json(summary_path, report)
if now - start >= args.duration_seconds:
break
deadline = min(now + args.sample_interval_seconds, start + args.duration_seconds)
while not interrupted and time.monotonic() < deadline:
time.sleep(max(0., min(1., deadline - time.monotonic())))
except (Exception, KeyboardInterrupt) as exc:
failure = str(exc) or type(exc).__name__
finally:
elapsed = previous['elapsed_seconds'] if previous else 0.
report.update(verdict(elapsed, progress_observed, failure, interrupted, gap))
report.update(elapsed_seconds=elapsed, reason=failure or ('interrupted' if interrupted else 'monitoring completed'),
sampling_gap=gap, samples=sample_count, progress_observed=progress_observed,
resource_peaks=peaks, ended_utc=datetime.now(timezone.utc).isoformat(),
child_cleanup='not applicable; attached process untouched')
if child:
if child.poll() is None:
child.terminate()
try:
child.wait(timeout=5)
report['child_cleanup'] = 'direct child terminated after monitoring'
except subprocess.TimeoutExpired:
child.kill()
child.wait()
report['child_cleanup'] = 'direct child killed after five-second termination timeout'
else:
report['child_cleanup'] = 'direct child had already exited'
report['child_exit_code'] = child.returncode
for sig, old in old_handlers.items():
signal.signal(sig, old)
atomic_json(summary_path, report)
print(json.dumps(report))
return 1 if failure else 0 if report['duration_liveness_pass'] else 2
if __name__ == '__main__':
raise SystemExit(main())
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""CPU physics fixture for delivery evidence; not robot or NAV/VLA acceptance.
Requires pybullet==3.2.7 for the CLI; the independent predicate uses stdlib.
Objects fall under gravity into generic trays. No robot, grasp controller,
perception model, ROS endpoint or production VerifyState server is exercised.
"""
import argparse
import json
import math
from pathlib import Path
import time
def verify_delivery(frames, bounds, body_id, destination_id):
"""Require 0.25 simulated seconds of fresh, continuous, released stability.
Simulator truth is privileged test evidence, not a production sensor.
Bounds describe the tray interior. Whole AABB containment is conservative.
"""
def reject(reason):
return dict(verified=False, reason=reason)
try:
if not frames:
return reject('missing_evidence')
if (len(bounds) != 2 or any(len(v) != 3 for v in bounds) or
not all(math.isfinite(x) for v in bounds for x in v) or
any(bounds[0][i] >= bounds[1][i] for i in range(3))):
return reject('invalid_bounds')
previous = None
for frame in frames:
stamp = frame['time']
if not math.isfinite(stamp) or (previous is not None and stamp <= previous):
return reject('invalid_time')
previous = stamp
end = frames[-1]['time']
selected = [f for f in frames if f['time'] >= end - .25 - 1e-9]
if len(selected) < 2 or end - selected[0]['time'] < .25 - 1e-9:
return reject('insufficient_stability_window')
previous = None
for frame in selected:
stamp = frame['time']
if not math.isfinite(stamp) or (previous is not None and
not 0 < stamp - previous <= .02):
return reject('discontinuous_time')
previous = stamp
if frame['body_id'] != body_id or frame['destination_id'] != destination_id:
return reject('identity_mismatch')
if frame['held'] is not False:
return reject('not_released')
if frame['floor_contact'] is not True:
return reject('no_destination_support')
speeds = [frame['linear_speed'], frame['angular_speed']]
if not all(math.isfinite(x) and x >= 0 for x in speeds):
return reject('invalid_velocity')
if speeds[0] > .02 or speeds[1] > .1:
return reject('not_stationary')
box = frame['aabb']
if (len(box) != 2 or any(len(v) != 3 for v in box) or
not all(math.isfinite(x) for v in box for x in v) or
any(box[0][i] > box[1][i] for i in range(3))):
return reject('invalid_geometry')
if any(box[0][i] < bounds[0][i] - .001 or
box[1][i] > bounds[1][i] + .001 for i in range(3)):
return reject('outside_destination')
return dict(verified=True, reason='released_supported_contained_stable')
except (KeyError, IndexError, TypeError, ValueError, OverflowError):
return reject('invalid_evidence')
def run_probe():
import pybullet as p
from importlib.metadata import version
started = time.monotonic()
client = p.connect(p.DIRECT)
cases = []
try:
# Reset per case so no earlier object's state can satisfy later proof.
for name, xyz, steps, hold, velocity, expected in (
('settled_in_correct_tray', (0, 0, .25), 480, False, None, True),
('settled_in_wrong_tray', (.7, 0, .25), 480, False, None, False),
('outside_both_trays', (1.3, 0, .25), 480, False, None, False),
('brief_transit_above_tray', (0, 0, .25), 30, False, None, False),
('held_inside_tray', (0, 0, .10), 480, True, None, False),
('moving_inside_tray', (0, 0, .10), 480, False, (.3, 0, 0), False),
):
p.resetSimulation(physicsClientId=client)
p.setGravity(0, 0, -9.81, physicsClientId=client)
p.setTimeStep(1 / 240, physicsClientId=client)
p.setPhysicsEngineParameter(numSolverIterations=100, physicsClientId=client)
def box(half, pos, mass=0):
shape = p.createCollisionShape(p.GEOM_BOX, halfExtents=half, physicsClientId=client)
return p.createMultiBody(baseMass=mass, baseCollisionShapeIndex=shape,
basePosition=pos, physicsClientId=client)
box([2, 2, .02], [0, 0, -.04])
floors = []
for x in (0., .7):
floors.append(box([.21, .21, .01], [x, 0, -.01]))
for sign in (-1, 1):
box([.01, .21, .15], [x + sign * .21, 0, .15])
box([.21, .01, .15], [x, sign * .21, .15])
obj = box([.025, .025, .025], xyz, .1)
if hold:
p.createConstraint(obj, -1, -1, -1, p.JOINT_FIXED, [0, 0, 0],
[0, 0, 0], xyz, physicsClientId=client)
frames = []
for step in range(steps):
# The negative moving fixture applies velocity only; no pose
# teleport or manufactured result is used as measured evidence.
if velocity and step >= steps - 60:
p.resetBaseVelocity(obj, velocity, physicsClientId=client)
p.stepSimulation(physicsClientId=client)
lin, ang = p.getBaseVelocity(obj, physicsClientId=client)
held = any(p.getConstraintInfo(p.getConstraintUniqueId(i, physicsClientId=client),
physicsClientId=client)[0] == obj
for i in range(p.getNumConstraints(physicsClientId=client)))
frames.append(dict(time=(step + 1) / 240, body_id=obj,
destination_id=floors[0], aabb=p.getAABB(obj, physicsClientId=client),
linear_speed=math.sqrt(sum(x*x for x in lin)),
angular_speed=math.sqrt(sum(x*x for x in ang)), held=held,
floor_contact=bool(p.getContactPoints(obj, floors[0], physicsClientId=client))))
verdict = verify_delivery(frames, [[-.2, -.2, 0], [.2, .2, .3]], obj, floors[0])
cases.append(dict(case=name, expected_verified=expected, verdict=verdict,
passed=verdict['verified'] == expected,
simulated_seconds=steps / 240, evidence_window=frames[-61:]))
return dict(scope='generic_delivery_predicate_physics_fixture',
simulator='PyBullet', version=version('pybullet'),
renderer='DIRECT', wall_seconds=time.monotonic()-started,
passed=all(c['passed'] for c in cases), cases=cases,
target_robot_acceptance=False, isaac_sim_executed=False,
production_verify_state_integrated=False,
grasp_navigation_perception_exercised=False)
finally:
p.disconnect(physicsClientId=client)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
result = run_probe()
args.output.parent.mkdir(parents=True, exist_ok=True)
temporary = args.output.with_suffix(args.output.suffix + '.tmp')
temporary.write_text(json.dumps(result, indent=2, allow_nan=False) + '\n')
temporary.replace(args.output)
print(json.dumps({k: v for k, v in result.items() if k != 'cases'}))
raise SystemExit(0 if result['passed'] else 1)
if __name__ == '__main__':
main()