simaudit 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simaudit/__init__.py +9 -0
- simaudit/__main__.py +2 -0
- simaudit/adapters/__init__.py +3 -0
- simaudit/adapters/base.py +68 -0
- simaudit/adapters/cartpole.py +66 -0
- simaudit/adapters/mujoco.py +108 -0
- simaudit/checks.py +220 -0
- simaudit/cli.py +53 -0
- simaudit/pytest_plugin.py +43 -0
- simaudit/release.py +107 -0
- simaudit/report.py +151 -0
- simaudit/worker.py +15 -0
- simaudit-0.1.0a1.data/data/share/simaudit/CITATION.cff +37 -0
- simaudit-0.1.0a1.data/data/share/simaudit/THIRD_PARTY.md +32 -0
- simaudit-0.1.0a1.data/data/share/simaudit/references.bib +17 -0
- simaudit-0.1.0a1.dist-info/METADATA +230 -0
- simaudit-0.1.0a1.dist-info/RECORD +21 -0
- simaudit-0.1.0a1.dist-info/WHEEL +5 -0
- simaudit-0.1.0a1.dist-info/entry_points.txt +5 -0
- simaudit-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- simaudit-0.1.0a1.dist-info/top_level.txt +1 -0
simaudit/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Simulator-independent diagnostics with explicit uncertainty and coverage."""
|
|
2
|
+
from .adapters import Adapter, Blowup, Unsupported
|
|
3
|
+
from .checks import check_assignment, check_observation, check_order
|
|
4
|
+
from .release import compare_releases
|
|
5
|
+
from .report import Feature, Modification, Report, Result, Status
|
|
6
|
+
|
|
7
|
+
__version__ = '0.1.0a1'
|
|
8
|
+
__all__ = ['Adapter','Blowup','Unsupported','check_assignment','check_observation',
|
|
9
|
+
'check_order','compare_releases','Feature','Modification','Report','Result','Status']
|
simaudit/__main__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Simulator-independent callable adapter; no optional simulator dependencies."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
import platform
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
import numpy as np
|
|
6
|
+
from ..report import Feature, Modification
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Unsupported(RuntimeError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Blowup(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Adapter:
|
|
19
|
+
name: str
|
|
20
|
+
step_fn: object = None
|
|
21
|
+
native_step_fn: object = None
|
|
22
|
+
distance_fn: object = None
|
|
23
|
+
observe_fn: object = None
|
|
24
|
+
reference_observe_fn: object = None
|
|
25
|
+
assignment_factory: object = None
|
|
26
|
+
features: list[Feature] = field(default_factory=lambda: [
|
|
27
|
+
Feature("dynamics", "unsupported", "User has not supplied a feature inventory")])
|
|
28
|
+
modifications: list[Modification] = field(default_factory=list)
|
|
29
|
+
versions: dict[str, str] = field(default_factory=dict)
|
|
30
|
+
scope: str = "Only supplied states, actions and configurations"
|
|
31
|
+
limitations: list[str] = field(default_factory=list)
|
|
32
|
+
dtype: str = "float64"
|
|
33
|
+
noise_floor: float = 0.0
|
|
34
|
+
smooth: bool | None = None
|
|
35
|
+
declared_order: float | None = None
|
|
36
|
+
order_blockers: list[str] = field(default_factory=list)
|
|
37
|
+
assignment_metadata: dict = field(default_factory=dict)
|
|
38
|
+
dt: float = 0.01
|
|
39
|
+
|
|
40
|
+
def __post_init__(self):
|
|
41
|
+
if np.dtype(self.dtype).kind != 'f':
|
|
42
|
+
raise ValueError("Adapter dtype must be floating point")
|
|
43
|
+
if not np.isfinite(self.noise_floor) or self.noise_floor < 0:
|
|
44
|
+
raise ValueError("noise_floor must be finite and nonnegative")
|
|
45
|
+
if not np.isfinite(self.dt) or self.dt <= 0:
|
|
46
|
+
raise ValueError("dt must be positive and finite")
|
|
47
|
+
self.versions = {"python": platform.python_version(), "numpy": np.__version__, **self.versions}
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def capabilities(self):
|
|
51
|
+
return {name for name, fn in (("step", self.step_fn), ("native_step", self.native_step_fn),
|
|
52
|
+
("observation", self.observe_fn), ("reference_observation", self.reference_observe_fn),
|
|
53
|
+
("assignment", self.assignment_factory)) if callable(fn)}
|
|
54
|
+
|
|
55
|
+
def step(self, state, action, dt):
|
|
56
|
+
return np.array(self.step_fn(np.array(state, copy=True), deepcopy(action), dt), copy=True)
|
|
57
|
+
|
|
58
|
+
def native_step(self, state, action, dt):
|
|
59
|
+
return np.array(self.native_step_fn(np.array(state, copy=True), deepcopy(action), dt), copy=True)
|
|
60
|
+
|
|
61
|
+
def distance(self, a, b):
|
|
62
|
+
a, b = np.asarray(a), np.asarray(b)
|
|
63
|
+
if a.shape != b.shape:
|
|
64
|
+
raise ValueError("State shapes differ")
|
|
65
|
+
value = float(self.distance_fn(a, b) if self.distance_fn else np.linalg.norm(a.astype(float)-b.astype(float)))
|
|
66
|
+
if not np.isfinite(value) or value < 0:
|
|
67
|
+
raise Blowup("Nonfinite or negative state distance")
|
|
68
|
+
return value
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Optional scalar Gymnasium adapter. Assignment methodology: Gebrekidan (2026)."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from .base import Adapter, Unsupported
|
|
4
|
+
from ..report import Feature, Modification
|
|
5
|
+
|
|
6
|
+
PARAMS = ('masscart','masspole','length','gravity','force_mag','tau')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cartpole(parameters=None, *, consistent=True):
|
|
10
|
+
import gymnasium
|
|
11
|
+
from gymnasium.envs.classic_control.cartpole import CartPoleEnv
|
|
12
|
+
params = dict(parameters or {})
|
|
13
|
+
if set(params)-set(PARAMS): raise Unsupported('Unsupported CartPole parameter')
|
|
14
|
+
if any(not np.isfinite(v) or v <= 0 for v in params.values()):
|
|
15
|
+
raise ValueError('CartPole parameters must be positive and finite')
|
|
16
|
+
|
|
17
|
+
def new():
|
|
18
|
+
env = CartPoleEnv()
|
|
19
|
+
env.reset(seed=0)
|
|
20
|
+
for key,value in params.items(): setattr(env,key,value)
|
|
21
|
+
if consistent:
|
|
22
|
+
env.total_mass = env.masscart + env.masspole
|
|
23
|
+
env.polemass_length = env.masspole * env.length
|
|
24
|
+
return env
|
|
25
|
+
|
|
26
|
+
env = new()
|
|
27
|
+
defaults = CartPoleEnv()
|
|
28
|
+
modifications = [Modification(k, getattr(defaults,k),v,
|
|
29
|
+
'fresh consistent reference' if consistent else 'direct attribute assignment') for k,v in params.items()]
|
|
30
|
+
if consistent:
|
|
31
|
+
for k in ('total_mass','polemass_length'):
|
|
32
|
+
if getattr(defaults,k)!=getattr(env,k):
|
|
33
|
+
modifications.append(Modification(k,getattr(defaults,k),getattr(env,k),'rebuild dependent constant'))
|
|
34
|
+
defaults.close()
|
|
35
|
+
|
|
36
|
+
def run(e,s,u,h):
|
|
37
|
+
e.state = np.array(s,dtype=np.float64,copy=True)
|
|
38
|
+
e.steps_beyond_terminated = None
|
|
39
|
+
e.tau = h
|
|
40
|
+
e.step(int(u))
|
|
41
|
+
return np.array(e.state,copy=True)
|
|
42
|
+
|
|
43
|
+
def native(s,u,h):
|
|
44
|
+
fresh = new()
|
|
45
|
+
try: return run(fresh,s,u,h)
|
|
46
|
+
finally: fresh.close()
|
|
47
|
+
|
|
48
|
+
def factory(name,value):
|
|
49
|
+
if name not in PARAMS: raise Unsupported(f'Assignment parameter not supported: {name}')
|
|
50
|
+
values = {**params,name:value}
|
|
51
|
+
return cartpole(values,consistent=False), cartpole(values,consistent=True)
|
|
52
|
+
|
|
53
|
+
def distance(x,y):
|
|
54
|
+
delta = x-y
|
|
55
|
+
delta[2] = np.arctan2(np.sin(delta[2]),np.cos(delta[2]))
|
|
56
|
+
return np.linalg.norm(delta)
|
|
57
|
+
|
|
58
|
+
return Adapter('CartPole-v1',lambda s,u,h:run(env,s,u,h),native,distance,
|
|
59
|
+
assignment_factory=factory, assignment_metadata={'partial_update': 'length' in params and not consistent,
|
|
60
|
+
'basis': 'length is read directly, but polemass_length is cached; adapter-specific source inspection'}, dt=env.tau, smooth=True, declared_order=1,
|
|
61
|
+
features=[Feature('dynamics','tested','Scalar unwrapped CartPole internal float64 state',True),
|
|
62
|
+
Feature('parameters','present_not_tested','Only the requested parameter is probed; other attributes remain untested',True),
|
|
63
|
+
Feature('wrappers','present_not_tested','TimeLimit/rendering/reward/termination excluded',False)],
|
|
64
|
+
modifications=modifications, versions={'gymnasium':gymnasium.__version__},
|
|
65
|
+
scope='Scalar CartPole internal one-step dynamics with explicit constant-rebuild reference',
|
|
66
|
+
limitations=['Vector CartPole and static attribute discovery are not implemented.'])
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Optional native adapters. No model features are silently disabled."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import numpy as np
|
|
4
|
+
from .base import Adapter, Blowup
|
|
5
|
+
from ..report import Feature
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def mujoco_xml(xml):
|
|
9
|
+
import mujoco as mj
|
|
10
|
+
model = mj.MjModel.from_xml_string(xml)
|
|
11
|
+
data = mj.MjData(model)
|
|
12
|
+
presence = {
|
|
13
|
+
'contacts':bool(np.any(model.geom_contype) or np.any(model.geom_conaffinity)),
|
|
14
|
+
'joint_limits':bool(np.any(model.jnt_limited)), 'actuators':bool(model.nu),
|
|
15
|
+
'damping':bool(np.any(model.dof_damping)), 'friction':bool(np.any(model.dof_frictionloss)),
|
|
16
|
+
'fluid':bool(model.opt.viscosity or model.opt.density), 'tendons':bool(model.ntendon),
|
|
17
|
+
'equality_constraints':bool(model.neq), 'plugins':bool(model.nplugin),
|
|
18
|
+
'flex':bool(model.nflex), 'mocap':bool(model.nmocap),
|
|
19
|
+
'callbacks':any(getattr(mj,'get_mjcb_'+name,lambda:None)() is not None
|
|
20
|
+
for name in ('control','passive','sensor','act_dyn','act_gain','act_bias')),
|
|
21
|
+
}
|
|
22
|
+
blockers = [k for k,v in presence.items() if v]
|
|
23
|
+
features = [Feature(k,'unsupported' if v else 'present_not_tested',
|
|
24
|
+
'Present: unsupported by autonomous smooth adapter' if v else 'Absent in compiled model',v)
|
|
25
|
+
for k,v in presence.items()]
|
|
26
|
+
features += [Feature('gravity','tested','Retained as compiled',True),
|
|
27
|
+
Feature('springs','tested','Retained as compiled',bool(np.any(model.jnt_stiffness)))]
|
|
28
|
+
initial_dt = float(model.opt.timestep)
|
|
29
|
+
|
|
30
|
+
def run(target,s,u,h):
|
|
31
|
+
if blockers:
|
|
32
|
+
from .base import Unsupported
|
|
33
|
+
raise Unsupported('Restricted native replay cannot represent: '+', '.join(blockers))
|
|
34
|
+
if s.shape != (model.nq+model.nv,): raise ValueError('Expected qpos followed by qvel')
|
|
35
|
+
if np.any(np.asarray(u)!=0): raise ValueError('Autonomous adapter accepts only zero action')
|
|
36
|
+
old = model.opt.timestep
|
|
37
|
+
try:
|
|
38
|
+
model.opt.timestep = h
|
|
39
|
+
mj.mj_resetData(model,target)
|
|
40
|
+
target.qpos[:] = s[:model.nq]
|
|
41
|
+
target.qvel[:] = s[model.nq:]
|
|
42
|
+
mj.mj_forward(model,target)
|
|
43
|
+
mj.mj_step(model,target)
|
|
44
|
+
if np.any(target.warning.number) or not np.isfinite(target.time) or abs(target.time-h)>1e-12:
|
|
45
|
+
raise Blowup('MuJoCo warning or automatic reset during native step')
|
|
46
|
+
return np.concatenate([target.qpos,target.qvel])
|
|
47
|
+
finally:
|
|
48
|
+
model.opt.timestep = old
|
|
49
|
+
|
|
50
|
+
def distance(x,y):
|
|
51
|
+
tangent = np.empty(model.nv)
|
|
52
|
+
mj.mj_differentiatePos(model,tangent,1.0,y[:model.nq],x[:model.nq])
|
|
53
|
+
return np.linalg.norm(np.concatenate([tangent,x[model.nq:]-y[model.nq:]]))
|
|
54
|
+
|
|
55
|
+
declared = {int(mj.mjtIntegrator.mjINT_EULER):1, int(mj.mjtIntegrator.mjINT_RK4):4}.get(int(model.opt.integrator))
|
|
56
|
+
if declared is None: blockers.append('unvalidated integrator family')
|
|
57
|
+
return Adapter('MuJoCo XML',lambda s,u,h:run(data,s,u,h),
|
|
58
|
+
lambda s,u,h:run(mj.MjData(model),s,u,h),distance,
|
|
59
|
+
features=features, versions={'mujoco':mj.__version__,'model_sha256':hashlib.sha256(xml.encode()).hexdigest()},
|
|
60
|
+
scope='Native engine step of supplied smooth autonomous XML; qpos/qvel only',
|
|
61
|
+
limitations=['Only autonomous rigid models without listed unsupported features are supported.',
|
|
62
|
+
'No claim about frame-skip trajectories, contacts, constraints, or control callbacks.'],
|
|
63
|
+
smooth=not blockers,order_blockers=blockers,declared_order=declared,dt=initial_dt)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def reacher():
|
|
67
|
+
import gymnasium as gym
|
|
68
|
+
import mujoco as mj
|
|
69
|
+
env = gym.make('Reacher-v5').unwrapped
|
|
70
|
+
env.reset(seed=0)
|
|
71
|
+
model = env.model
|
|
72
|
+
dt = float(env.dt)
|
|
73
|
+
# Each probe begins from the same complete data snapshot, then specifies qpos/qvel.
|
|
74
|
+
snapshot = mj.MjData(model)
|
|
75
|
+
mj.mj_copyData(snapshot,model,env.data)
|
|
76
|
+
|
|
77
|
+
def run(s,u,h,refresh=False):
|
|
78
|
+
if h != dt: raise ValueError('Reacher adapter uses its native frame-skip interval')
|
|
79
|
+
if s.shape != (model.nq+model.nv,): raise ValueError('Expected qpos followed by qvel')
|
|
80
|
+
mj.mj_copyData(env.data,model,snapshot)
|
|
81
|
+
env.set_state(s[:model.nq],s[model.nq:])
|
|
82
|
+
observation,*_ = env.step(np.asarray(u,dtype=float))
|
|
83
|
+
if np.any(env.data.warning.number): raise Blowup('MuJoCo warning during Reacher step')
|
|
84
|
+
state = np.concatenate([env.data.qpos.copy(),env.data.qvel.copy()])
|
|
85
|
+
if refresh:
|
|
86
|
+
mj.mj_forward(model,env.data)
|
|
87
|
+
observation = env._get_obs()
|
|
88
|
+
return state,np.array(observation,copy=True)
|
|
89
|
+
|
|
90
|
+
def native(s,u,h):
|
|
91
|
+
other = gym.make('Reacher-v5').unwrapped
|
|
92
|
+
try:
|
|
93
|
+
other.reset(seed=0)
|
|
94
|
+
mj.mj_copyData(other.data,other.model,snapshot)
|
|
95
|
+
other.set_state(s[:model.nq],s[model.nq:])
|
|
96
|
+
other.step(np.asarray(u,dtype=float))
|
|
97
|
+
return np.concatenate([other.data.qpos.copy(),other.data.qvel.copy()])
|
|
98
|
+
finally: other.close()
|
|
99
|
+
|
|
100
|
+
return Adapter('Reacher-v5',lambda s,u,h:run(s,u,h)[0],native,
|
|
101
|
+
observe_fn=lambda s,u,h:run(s,u,h)[1], reference_observe_fn=lambda s,u,h:run(s,u,h,True)[1],
|
|
102
|
+
features=[Feature('observations','tested','Shipped observation versus mj_forward recomputation',True),
|
|
103
|
+
Feature('actuators','present_not_tested','Retained native control; actuation correctness untested',True),
|
|
104
|
+
Feature('dynamics','present_not_tested','Native replay checked; physics correctness untested',True),
|
|
105
|
+
Feature('wrappers','present_not_tested','Unwrapped; TimeLimit excluded',False)],
|
|
106
|
+
versions={'gymnasium':gym.__version__,'mujoco':mj.__version__},dt=dt,
|
|
107
|
+
limitations=['Reference reuses the shipped observation formula; this checks cache consistency only.',
|
|
108
|
+
'Fixed reset snapshot; no arbitrary user callbacks or wrappers are represented.'])
|
simaudit/checks.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Independent scoped checks. Method attribution: see CITATION.cff and THIRD_PARTY.md."""
|
|
2
|
+
from dataclasses import asdict
|
|
3
|
+
import math
|
|
4
|
+
import numpy as np
|
|
5
|
+
from .report import Feature, Modification, Result, Status
|
|
6
|
+
from .adapters import Blowup, Unsupported
|
|
7
|
+
|
|
8
|
+
LIMITS = {
|
|
9
|
+
'assignment': ['Only explicitly supplied parameters and probes are assessed.',
|
|
10
|
+
'The fresh factory defines the parameter contract; direct mutation may not be a supported simulator API.'],
|
|
11
|
+
'order': ['Convergence does not establish correct equations or accurate trajectories.',
|
|
12
|
+
'Non-smooth, noisy, stiff and unresolved regimes cannot be diagnosed by these slopes.',
|
|
13
|
+
'Only the supplied interval and starts are assessed; no energy or model-fault classification.'],
|
|
14
|
+
'observation': ['Only the supplied reference observation map and probes are assessed.',
|
|
15
|
+
'A shared error in simulator and reference can be invisible.'],
|
|
16
|
+
'release': ['A changed first step is a compatibility difference, not proof of a simulator defect.',
|
|
17
|
+
'Reward, termination, reset, spaces, RNG and rendering are not compared.',
|
|
18
|
+
'Later separation does not identify chaos; unexecuted releases remain untested.'],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def result(a, check, status, reason, evidence=None, fault=None, modifications=()):
|
|
23
|
+
coverage = [Feature(**asdict(f)) for f in a.features]
|
|
24
|
+
if not coverage:
|
|
25
|
+
coverage = [Feature('dynamics', 'unsupported', 'No feature inventory supplied')]
|
|
26
|
+
return Result(check, status, reason, a.scope, LIMITS.get(check, []) + a.limitations + [
|
|
27
|
+
'Adapter inventory and reference correctness remain caller responsibilities.'], coverage,
|
|
28
|
+
[*a.modifications, *modifications], a.versions, evidence or {}, fault)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def probes(states, actions):
|
|
32
|
+
states = [np.asarray(s) for s in states]
|
|
33
|
+
actions = list(actions)
|
|
34
|
+
if not states or len(states) != len(actions):
|
|
35
|
+
raise ValueError('Supply a nonempty state/action pair list with matching lengths')
|
|
36
|
+
if any(not np.all(np.isfinite(s)) for s in states):
|
|
37
|
+
raise ValueError('Input states must be finite')
|
|
38
|
+
return states, actions
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tolerance(value):
|
|
42
|
+
if not np.isfinite(value) or value < 0:
|
|
43
|
+
raise ValueError('Tolerance must be finite and nonnegative')
|
|
44
|
+
return float(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def self_test(a, states, actions, dts, atol=1e-12):
|
|
48
|
+
"""Compare native steps, replay after interleaving, and two-step continuation."""
|
|
49
|
+
for state, action in zip(states, actions):
|
|
50
|
+
for dt in dts:
|
|
51
|
+
x = a.step(state, action, dt)
|
|
52
|
+
native = a.native_step(state, action, dt)
|
|
53
|
+
if not np.all(np.isfinite(x)) or not np.all(np.isfinite(native)):
|
|
54
|
+
raise Blowup('Nonfinite output during adapter self-test')
|
|
55
|
+
replay_floor, _ = precision(a, [x, native], atol)
|
|
56
|
+
if a.distance(x, native) > replay_floor:
|
|
57
|
+
return False, 'native-step equivalence failed'
|
|
58
|
+
# A different input/continuation must not contaminate a replay.
|
|
59
|
+
y = a.step(x, action, dt)
|
|
60
|
+
replay = a.step(state, action, dt)
|
|
61
|
+
native_y = a.native_step(native, action, dt)
|
|
62
|
+
continuation_floor, _ = precision(a, [y, native_y], atol)
|
|
63
|
+
if a.distance(x, replay) > replay_floor or a.distance(y, native_y) > continuation_floor:
|
|
64
|
+
return False, 'state restoration or continuation equivalence failed'
|
|
65
|
+
return True, 'native-step equivalence and interleaved replay passed'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def guard(a, check, states, actions, dts, needed=()):
|
|
69
|
+
missing = {'step', 'native_step', *needed} - a.capabilities
|
|
70
|
+
if missing:
|
|
71
|
+
return result(a, check, Status.SKIPPED, 'Missing capabilities: ' + ', '.join(sorted(missing)))
|
|
72
|
+
try:
|
|
73
|
+
ok, reason = self_test(a, states, actions, dts)
|
|
74
|
+
except Blowup as exc:
|
|
75
|
+
return result(a, check, Status.BLOWUP, str(exc))
|
|
76
|
+
except Unsupported as exc:
|
|
77
|
+
return result(a, check, Status.SKIPPED, str(exc))
|
|
78
|
+
if not ok and a.noise_floor > 0:
|
|
79
|
+
return result(a, check, Status.ABSTAIN, 'Declared noise prevents verifying deterministic native equivalence/restoration; downstream check blocked')
|
|
80
|
+
if not ok:
|
|
81
|
+
return result(a, check, Status.FAIL, reason + '; downstream check blocked', fault='adapter')
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def precision(a, arrays, atol):
|
|
86
|
+
eps = max([float(np.finfo(a.dtype).eps)] + [float(np.finfo(x.dtype).eps) for x in arrays if x.dtype.kind == 'f'])
|
|
87
|
+
scale = 1 + max(float(np.linalg.norm(x)) for x in arrays)
|
|
88
|
+
return max(atol, a.noise_floor, 50 * eps * scale), eps
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_assignment(a, parameter, value, states, actions, *, atol=1e-12):
|
|
92
|
+
states, actions = probes(states, actions)
|
|
93
|
+
atol = tolerance(atol)
|
|
94
|
+
blocked = guard(a, 'assignment', states, actions, [a.dt], ['assignment'])
|
|
95
|
+
if blocked: return blocked
|
|
96
|
+
try:
|
|
97
|
+
assigned, fresh = a.assignment_factory(parameter, value)
|
|
98
|
+
except Unsupported as exc:
|
|
99
|
+
return result(a, 'assignment', Status.SKIPPED, str(exc))
|
|
100
|
+
for candidate in (assigned, fresh):
|
|
101
|
+
blocked = guard(candidate, 'assignment', states, actions, [candidate.dt])
|
|
102
|
+
if blocked: return blocked
|
|
103
|
+
if not assigned.modifications or not fresh.modifications:
|
|
104
|
+
return result(a, 'assignment', Status.FAIL, 'Assignment factory omitted modification records', fault='adapter')
|
|
105
|
+
rows = []
|
|
106
|
+
try:
|
|
107
|
+
for s, u in zip(states, actions):
|
|
108
|
+
b, d, f = [adapter.step(s, u, adapter.dt) for adapter in (a, assigned, fresh)]
|
|
109
|
+
floor, eps = precision(a, [b, d, f], atol)
|
|
110
|
+
rows.append(dict(state=s.tolist(), action=np.asarray(u).tolist(), baseline=b.tolist(),
|
|
111
|
+
assigned=d.tolist(), fresh=f.tolist(), floor=floor,
|
|
112
|
+
baseline_assigned=a.distance(b,d), baseline_fresh=a.distance(b,f),
|
|
113
|
+
assigned_fresh=a.distance(d,f), low_precision=eps > 1e-10))
|
|
114
|
+
except Blowup as exc:
|
|
115
|
+
return result(a, 'assignment', Status.BLOWUP, str(exc))
|
|
116
|
+
effective = [r for r in rows if r['baseline_fresh'] > r['floor']]
|
|
117
|
+
mismatch = any(r['assigned_fresh'] > r['floor'] for r in rows)
|
|
118
|
+
no_op = all(r['baseline_assigned'] <= r['floor'] for r in rows)
|
|
119
|
+
if mismatch:
|
|
120
|
+
kind = 'no_op' if no_op else 'stale'
|
|
121
|
+
if not no_op and assigned.assignment_metadata.get('partial_update'):
|
|
122
|
+
kind = 'partly_stale'
|
|
123
|
+
status, reason = Status.FAIL, f'{parameter}: {kind} relative to supplied fresh-reference contract'
|
|
124
|
+
elif not effective or any(r['low_precision'] for r in rows) or a.noise_floor:
|
|
125
|
+
kind, status, reason = 'unresolved', Status.ABSTAIN, 'Insufficient parameter excitation or precision/noise blocks a consistency verdict'
|
|
126
|
+
else:
|
|
127
|
+
kind, status, reason = 'consistent', Status.PASS, f'{parameter}: assignment matches the fresh reference on supplied probes'
|
|
128
|
+
return result(a, 'assignment', status, reason, dict(parameter=parameter, value=value, classification=kind,
|
|
129
|
+
atol=atol, adapter_assignment_metadata=assigned.assignment_metadata, self_test='passed in baseline, assigned and fresh configurations', probes=rows),
|
|
130
|
+
kind if mismatch else None, [*assigned.modifications, *fresh.modifications])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_order(a, states, actions, *, interval=None, atol=1e-12, order_tolerance=0.5, slope_tolerance=0.5):
|
|
134
|
+
states, actions = probes(states, actions)
|
|
135
|
+
atol, order_tolerance, slope_tolerance = map(tolerance, (atol, order_tolerance, slope_tolerance))
|
|
136
|
+
h = a.dt if interval is None else float(interval)
|
|
137
|
+
if not np.isfinite(h) or h <= 0: raise ValueError('Interval must be positive and finite')
|
|
138
|
+
if a.declared_order is None:
|
|
139
|
+
return result(a, 'order', Status.SKIPPED, 'Missing declared_order')
|
|
140
|
+
if not np.isfinite(a.declared_order) or a.declared_order <= 0:
|
|
141
|
+
raise ValueError('declared_order must be positive and finite')
|
|
142
|
+
modifications = [Modification('timestep', a.dt, h/m, f'Fixed-interval refinement: {m} substeps')
|
|
143
|
+
for m in (1,2,4,8) if h/m != a.dt]
|
|
144
|
+
unknown = [f.name for f in a.features if f.status=='unsupported' and f.present is not False]
|
|
145
|
+
if a.smooth is not True or a.order_blockers or unknown:
|
|
146
|
+
return result(a, 'order', Status.PARTIAL, 'Smoothness not established or unsupported features: ' +
|
|
147
|
+
', '.join(a.order_blockers + unknown or ['smooth']), modifications=modifications)
|
|
148
|
+
blocked = guard(a, 'order', states, actions, [h/m for m in (1,2,4,8)])
|
|
149
|
+
if blocked:
|
|
150
|
+
blocked.modifications.extend(modifications)
|
|
151
|
+
return blocked
|
|
152
|
+
rows = []
|
|
153
|
+
try:
|
|
154
|
+
for s, u in zip(states, actions):
|
|
155
|
+
endpoints = []
|
|
156
|
+
for m in (1,2,4,8):
|
|
157
|
+
x = s.copy()
|
|
158
|
+
for _ in range(m):
|
|
159
|
+
x = a.step(x, u, h/m)
|
|
160
|
+
if not np.all(np.isfinite(x)): raise Blowup('Nonfinite refinement trajectory')
|
|
161
|
+
endpoints.append(x)
|
|
162
|
+
floor, eps = precision(a, endpoints, atol)
|
|
163
|
+
d = [a.distance(x,y) for x,y in zip(endpoints, endpoints[1:])]
|
|
164
|
+
p = [math.log2(d[i]/d[i+1]) for i in (0,1)] if min(d)>floor else [None,None]
|
|
165
|
+
if eps > 1e-10 or a.noise_floor or p[0] is None:
|
|
166
|
+
label = 'ABSTAIN'
|
|
167
|
+
elif abs(p[0]-p[1]) > slope_tolerance:
|
|
168
|
+
label = 'ABSTAIN'
|
|
169
|
+
elif p[1] < a.declared_order-order_tolerance:
|
|
170
|
+
label = 'FAIL'
|
|
171
|
+
elif p[1] > a.declared_order+order_tolerance:
|
|
172
|
+
label = 'ABSTAIN'
|
|
173
|
+
else:
|
|
174
|
+
label = 'PASS'
|
|
175
|
+
rows.append(dict(state=s.tolist(), action=np.asarray(u).tolist(), endpoints=[e.tolist() for e in endpoints],
|
|
176
|
+
differences=d, floor=floor, slopes=p, status=label))
|
|
177
|
+
except Unsupported as exc:
|
|
178
|
+
return result(a, 'order', Status.PARTIAL, str(exc), {'probes': rows}, modifications=modifications)
|
|
179
|
+
except Blowup as exc:
|
|
180
|
+
return result(a, 'order', Status.BLOWUP, str(exc), {'probes': rows}, modifications=modifications)
|
|
181
|
+
labels = {r['status'] for r in rows}
|
|
182
|
+
status = Status.FAIL if 'FAIL' in labels else Status.PASS if labels=={'PASS'} else Status.ABSTAIN
|
|
183
|
+
reason = {Status.FAIL:'Resolved order below declaration on one or more supplied probes',
|
|
184
|
+
Status.PASS:'Stable slopes consistent with declared order on all supplied probes',
|
|
185
|
+
Status.ABSTAIN:'Order unresolved: precision/noise floor, unstable slopes, or order outside assessment band'}[status]
|
|
186
|
+
return result(a, 'order', status, reason, dict(interval=h, declared_order=a.declared_order,
|
|
187
|
+
atol=atol, order_tolerance=order_tolerance, slope_tolerance=slope_tolerance,
|
|
188
|
+
self_test='passed at all four timesteps', probes=rows), 'integrator_order' if status==Status.FAIL else None, modifications)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def check_observation(a, states, actions, *, atol=1e-12):
|
|
192
|
+
states, actions = probes(states, actions)
|
|
193
|
+
atol = tolerance(atol)
|
|
194
|
+
blocked = guard(a, 'observation', states, actions, [a.dt], ['observation','reference_observation'])
|
|
195
|
+
if blocked: return blocked
|
|
196
|
+
rows = []
|
|
197
|
+
try:
|
|
198
|
+
for s,u in zip(states,actions):
|
|
199
|
+
observed = np.asarray(a.observe_fn(s.copy(),u,a.dt))
|
|
200
|
+
reference = np.asarray(a.reference_observe_fn(s.copy(),u,a.dt))
|
|
201
|
+
if observed.shape != reference.shape: raise ValueError('Observation shapes differ')
|
|
202
|
+
if not np.all(np.isfinite(observed)) or not np.all(np.isfinite(reference)):
|
|
203
|
+
raise Blowup('Nonfinite observation')
|
|
204
|
+
eps = max([float(np.finfo(a.dtype).eps)] + [float(np.finfo(x.dtype).eps) for x in (observed,reference) if x.dtype.kind=='f'])
|
|
205
|
+
floor = max(atol, a.noise_floor, 8*eps*(1+float(np.max(np.abs(reference)))))
|
|
206
|
+
error = float(np.max(np.abs(observed.astype(float)-reference.astype(float))))
|
|
207
|
+
rows.append(dict(state=s.tolist(), action=np.asarray(u).tolist(), observed=observed.tolist(),
|
|
208
|
+
reference=reference.tolist(), error=error, floor=floor, low_precision=eps>1e-10))
|
|
209
|
+
except Unsupported as exc:
|
|
210
|
+
return result(a, 'observation', Status.SKIPPED, str(exc), {'probes':rows})
|
|
211
|
+
except Blowup as exc:
|
|
212
|
+
return result(a, 'observation', Status.BLOWUP, str(exc), {'probes':rows})
|
|
213
|
+
fail = any(r['error']>r['floor'] for r in rows)
|
|
214
|
+
uncertain = any(r['low_precision'] for r in rows) or a.noise_floor > 0
|
|
215
|
+
status = Status.FAIL if fail else Status.ABSTAIN if uncertain else Status.PASS
|
|
216
|
+
return result(a, 'observation', status,
|
|
217
|
+
'Observation differs from reference above floor' if fail else
|
|
218
|
+
'Precision/noise blocks a consistency verdict; above-floor faults can still be flagged' if uncertain else
|
|
219
|
+
'Observation agrees with reference on supplied probes',
|
|
220
|
+
dict(atol=atol, self_test='passed', probes=rows), 'observation' if fail else None)
|
simaudit/cli.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
from . import Report, check_assignment, check_observation, check_order, compare_releases
|
|
6
|
+
from .release import load_factory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv=None):
|
|
10
|
+
parser = argparse.ArgumentParser(description='Scoped diagnostics for any simulator through a trusted module:function adapter factory')
|
|
11
|
+
parser.add_argument('--version',action='version',version='simaudit 0.1.0a1')
|
|
12
|
+
sub = parser.add_subparsers(dest='check',required=True)
|
|
13
|
+
for name in ('assignment','order','observation','release'):
|
|
14
|
+
cmd = sub.add_parser(name)
|
|
15
|
+
cmd.add_argument('--factory',required=True,help='Trusted Python module:function returning simaudit.Adapter')
|
|
16
|
+
cmd.add_argument('--probes',required=True,type=Path,help='JSON object with states and actions arrays')
|
|
17
|
+
cmd.add_argument('--json',type=Path,help='Write full report here')
|
|
18
|
+
cmd.add_argument('--strict',action='store_true')
|
|
19
|
+
cmd.add_argument('--atol',type=float,default=1e-13 if name=='release' else 1e-12)
|
|
20
|
+
if name=='assignment':
|
|
21
|
+
cmd.add_argument('--parameter',required=True)
|
|
22
|
+
cmd.add_argument('--value',type=float,required=True)
|
|
23
|
+
if name=='order': cmd.add_argument('--interval',type=float)
|
|
24
|
+
if name=='release':
|
|
25
|
+
cmd.add_argument('--factory-b',help='Explicit second factory for controlled variant comparisons')
|
|
26
|
+
cmd.add_argument('--python-a',required=True)
|
|
27
|
+
cmd.add_argument('--python-b',required=True)
|
|
28
|
+
cmd.add_argument('--steps',type=int,default=5)
|
|
29
|
+
cmd.add_argument('--timeout',type=float,default=60)
|
|
30
|
+
args = parser.parse_args(argv)
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(args.probes.read_text())
|
|
33
|
+
if set(data) != {'states','actions'}: raise ValueError('Probe file must contain exactly states and actions')
|
|
34
|
+
states,actions = data['states'],data['actions']
|
|
35
|
+
if args.check=='release':
|
|
36
|
+
r = compare_releases(args.factory,args.python_a,args.python_b,states,actions,
|
|
37
|
+
steps=args.steps,atol=args.atol,timeout=args.timeout,factory_b=args.factory_b)
|
|
38
|
+
else:
|
|
39
|
+
a = load_factory(args.factory)
|
|
40
|
+
if args.check=='assignment': r = check_assignment(a,args.parameter,args.value,states,actions,atol=args.atol)
|
|
41
|
+
elif args.check=='order': r = check_order(a,states,actions,interval=args.interval,atol=args.atol)
|
|
42
|
+
else: r = check_observation(a,states,actions,atol=args.atol)
|
|
43
|
+
report = Report([r])
|
|
44
|
+
if args.json: report.write(args.json)
|
|
45
|
+
print(report.summary())
|
|
46
|
+
return report.exit_code(args.strict)
|
|
47
|
+
except (ValueError, TypeError, OSError, ImportError, AttributeError) as exc:
|
|
48
|
+
print(f'simaudit: configuration error: {exc}',file=sys.stderr)
|
|
49
|
+
return 2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == '__main__':
|
|
53
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Optional pytest fixture preserving all six diagnostic statuses."""
|
|
2
|
+
import pytest
|
|
3
|
+
from .report import Report, Status
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pytest_addoption(parser):
|
|
7
|
+
group = parser.getgroup('simaudit')
|
|
8
|
+
group.addoption('--simaudit-strict',action='store_true',help='Fail session on any non-pass audit result')
|
|
9
|
+
group.addoption('--simaudit-json',help='Write collected audit results to JSON (serial pytest only)')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pytest_configure(config):
|
|
13
|
+
config._simaudit_results = []
|
|
14
|
+
if config.getoption('--simaudit-json') and getattr(config.option,'numprocesses',0):
|
|
15
|
+
raise pytest.UsageError('--simaudit-json currently requires serial pytest')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def simaudit_check(request):
|
|
20
|
+
def check(result):
|
|
21
|
+
# Validate now, before appending to the session report.
|
|
22
|
+
Report.loads(Report([result]).dumps())
|
|
23
|
+
request.config._simaudit_results.append(result)
|
|
24
|
+
request.node.user_properties.append(('simaudit_status',result.status.value))
|
|
25
|
+
message = Report([result]).summary()
|
|
26
|
+
if result.status == Status.FAIL: pytest.fail(message,pytrace=False)
|
|
27
|
+
if result.status != Status.PASS: pytest.skip(message)
|
|
28
|
+
return result
|
|
29
|
+
return check
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pytest_sessionfinish(session,exitstatus):
|
|
33
|
+
report = Report(session.config._simaudit_results)
|
|
34
|
+
path = session.config.getoption('--simaudit-json')
|
|
35
|
+
if path: report.write(path)
|
|
36
|
+
if report.results and session.config.getoption('--simaudit-strict') and report.exit_code(True) and exitstatus==0:
|
|
37
|
+
session.exitstatus = 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def pytest_terminal_summary(terminalreporter,exitstatus,config):
|
|
41
|
+
if config._simaudit_results:
|
|
42
|
+
terminalreporter.section('simaudit (original diagnostic statuses)')
|
|
43
|
+
terminalreporter.write_line(Report(config._simaudit_results).summary())
|
simaudit/release.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Run user factories in separate interpreters with identical inputs, without installing anything."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import numpy as np
|
|
12
|
+
from .adapters import Adapter
|
|
13
|
+
from .checks import guard, probes, result, tolerance
|
|
14
|
+
from .report import Feature, Report, Status
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_factory(spec):
|
|
18
|
+
module, separator, name = spec.partition(':')
|
|
19
|
+
if not separator or not module or not name:
|
|
20
|
+
raise ValueError('Factory must be module:function (a trusted local Python callable)')
|
|
21
|
+
return getattr(importlib.import_module(module),name)()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def collect(adapter, states, actions, steps=1):
|
|
25
|
+
states, actions = probes(states,actions)
|
|
26
|
+
if type(steps) is not int or steps < 1: raise ValueError('steps must be a positive integer')
|
|
27
|
+
blocked = guard(adapter,'release',states,actions,[adapter.dt])
|
|
28
|
+
if blocked: return Report([blocked])
|
|
29
|
+
trajectories = []
|
|
30
|
+
from .adapters import Blowup
|
|
31
|
+
try:
|
|
32
|
+
for s,u in zip(states,actions):
|
|
33
|
+
path = []
|
|
34
|
+
for _ in range(steps):
|
|
35
|
+
s = adapter.step(s,u,adapter.dt)
|
|
36
|
+
if not np.all(np.isfinite(s)): raise Blowup('Nonfinite release trajectory')
|
|
37
|
+
path.append(s.tolist())
|
|
38
|
+
trajectories.append(path)
|
|
39
|
+
except Blowup as exc:
|
|
40
|
+
return Report([result(adapter,'release',Status.BLOWUP,str(exc))])
|
|
41
|
+
return Report([result(adapter,'release',Status.PASS,'Worker replay self-test passed; trajectory collection completed',
|
|
42
|
+
{'trajectories':trajectories,'dt':adapter.dt,'dtype':adapter.dtype,'noise_floor':adapter.noise_floor})])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compare_releases(factory, python_a, python_b, states, actions, *, steps=5, atol=1e-13, timeout=60, factory_b=None):
|
|
46
|
+
states, actions = probes(states,actions)
|
|
47
|
+
atol = tolerance(atol)
|
|
48
|
+
payload = dict(factory=factory,states=[s.tolist() for s in states],
|
|
49
|
+
actions=[np.asarray(u).tolist() for u in actions],steps=steps)
|
|
50
|
+
encoded = json.dumps(payload,allow_nan=False)
|
|
51
|
+
adapter = Adapter('isolated releases',features=[Feature('dynamics','tested','Paired worker first steps',True)],
|
|
52
|
+
scope='Same supplied inputs and factory executed in two explicitly supplied interpreters')
|
|
53
|
+
workers = []
|
|
54
|
+
env = dict(os.environ)
|
|
55
|
+
# Expose ONLY this package to the workers. Putting the caller's site-packages
|
|
56
|
+
# on PYTHONPATH would shadow each interpreter's own numpy/simulator versions.
|
|
57
|
+
isolated = tempfile.TemporaryDirectory(prefix='simaudit-worker-')
|
|
58
|
+
package_dir = Path(__file__).resolve().parent
|
|
59
|
+
shutil.copytree(package_dir, Path(isolated.name) / 'simaudit',
|
|
60
|
+
ignore=shutil.ignore_patterns('__pycache__', '*.pyc'))
|
|
61
|
+
inherited = env.get('PYTHONPATH')
|
|
62
|
+
env['PYTHONPATH'] = isolated.name + (os.pathsep + inherited if inherited else '')
|
|
63
|
+
try:
|
|
64
|
+
return _run_workers(adapter, payload, encoded, factory, factory_b, python_a, python_b,
|
|
65
|
+
atol, timeout, env, workers)
|
|
66
|
+
finally:
|
|
67
|
+
isolated.cleanup()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run_workers(adapter, payload, encoded, factory, factory_b, python_a, python_b, atol, timeout, env, workers):
|
|
71
|
+
for executable, worker_factory in ((python_a,factory),(python_b,factory_b or factory)):
|
|
72
|
+
worker_input = json.dumps({**payload, "factory":worker_factory},allow_nan=False)
|
|
73
|
+
try:
|
|
74
|
+
run = subprocess.run([str(executable),'-m','simaudit.worker'],input=worker_input,text=True,
|
|
75
|
+
capture_output=True,timeout=timeout,env=env)
|
|
76
|
+
except (OSError,subprocess.TimeoutExpired) as exc:
|
|
77
|
+
return result(adapter,'release',Status.SKIPPED,f'Worker unavailable or timed out: {executable}: {exc}',
|
|
78
|
+
{'untested_interpreter':str(executable)})
|
|
79
|
+
if run.returncode:
|
|
80
|
+
return result(adapter,'release',Status.SKIPPED,f'Worker could not collect: {executable}',
|
|
81
|
+
{'stderr':run.stderr[-4000:],'untested_interpreter':str(executable)})
|
|
82
|
+
worker = Report.loads(run.stdout).results[0]
|
|
83
|
+
workers.append(worker)
|
|
84
|
+
if worker.status != Status.PASS:
|
|
85
|
+
return result(adapter,'release',worker.status,'Worker did not pass: '+worker.reason,
|
|
86
|
+
{'worker':json.loads(Report([worker]).dumps())})
|
|
87
|
+
left,right = workers
|
|
88
|
+
adapter.versions = {**{f'a:{k}':v for k,v in left.versions.items()},**{f'b:{k}':v for k,v in right.versions.items()}}
|
|
89
|
+
a,b = [np.asarray(w.evidence['trajectories']) for w in workers]
|
|
90
|
+
evidence = dict(input_sha256=hashlib.sha256(encoded.encode()).hexdigest(),inputs=payload,atol=atol,
|
|
91
|
+
interpreters=[str(python_a),str(python_b)], factories=[factory,factory_b or factory], workers=[json.loads(Report([w]).dumps()) for w in workers])
|
|
92
|
+
if a.shape!=b.shape or left.evidence['dt']!=right.evidence['dt']:
|
|
93
|
+
return result(adapter,'release',Status.PARTIAL,'Worker dimensions or timesteps differ; comparison invalid',evidence)
|
|
94
|
+
first = float(np.max(np.abs(a[:,0]-b[:,0])))
|
|
95
|
+
evidence['first_step_max_difference'] = first
|
|
96
|
+
evidence['later_max_difference'] = float(np.max(np.abs(a-b)))
|
|
97
|
+
low_precision = any(np.finfo(w.evidence['dtype']).eps>1e-10 or w.evidence['noise_floor']>0 for w in workers)
|
|
98
|
+
evidence['bitwise_identical_recorded_trajectories'] = a.tobytes()==b.tobytes()
|
|
99
|
+
noise_floor = max(atol, *(w.evidence['noise_floor'] for w in workers))
|
|
100
|
+
if low_precision:
|
|
101
|
+
noise_floor = max(noise_floor,*(8*np.finfo(w.evidence['dtype']).eps*(1+np.max(np.abs(a[:,0]))) for w in workers))
|
|
102
|
+
evidence['effective_floor'] = float(noise_floor)
|
|
103
|
+
if first > noise_floor:
|
|
104
|
+
return result(adapter,'release',Status.FAIL,'First-step compatibility change above supplied floor',evidence,'changed_dynamics')
|
|
105
|
+
if first or low_precision:
|
|
106
|
+
return result(adapter,'release',Status.ABSTAIN,'First-step difference at/below precision or noise floor',evidence)
|
|
107
|
+
return result(adapter,'release',Status.PASS,'Recorded first steps agree exactly on supplied probes',evidence)
|
simaudit/report.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Strict report envelope. Evidence is an intentionally open JSON object."""
|
|
2
|
+
from dataclasses import asdict, dataclass, field
|
|
3
|
+
from enum import Enum
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Status(str, Enum):
|
|
9
|
+
PASS = "PASS"
|
|
10
|
+
FAIL = "FAIL"
|
|
11
|
+
ABSTAIN = "ABSTAIN"
|
|
12
|
+
SKIPPED = "SKIPPED"
|
|
13
|
+
PARTIAL = "PARTIAL"
|
|
14
|
+
BLOWUP = "BLOWUP"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
COVERAGE = {"tested", "disabled_for_test", "present_not_tested", "unsupported"}
|
|
18
|
+
FEATURES = {"dynamics", "parameters", "observations", "gravity", "springs", "contacts",
|
|
19
|
+
"joint_limits", "actuators", "damping", "friction", "fluid", "tendons",
|
|
20
|
+
"equality_constraints", "plugins", "wrappers", "flex", "mocap", "callbacks"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Feature:
|
|
25
|
+
name: str
|
|
26
|
+
status: str
|
|
27
|
+
note: str
|
|
28
|
+
present: bool | None = None
|
|
29
|
+
|
|
30
|
+
def __post_init__(self):
|
|
31
|
+
if not self.name or self.status not in COVERAGE or not self.note:
|
|
32
|
+
raise ValueError("Invalid feature inventory entry")
|
|
33
|
+
if self.present is not None and type(self.present) is not bool:
|
|
34
|
+
raise ValueError("Feature presence must be boolean or null")
|
|
35
|
+
if self.name not in FEATURES:
|
|
36
|
+
self.status = "unsupported"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Modification:
|
|
41
|
+
name: str
|
|
42
|
+
before: object
|
|
43
|
+
after: object
|
|
44
|
+
reason: str
|
|
45
|
+
|
|
46
|
+
def __post_init__(self):
|
|
47
|
+
if not self.name or not self.reason:
|
|
48
|
+
raise ValueError("Modification needs a name and reason")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Result:
|
|
53
|
+
check: str
|
|
54
|
+
status: Status
|
|
55
|
+
reason: str
|
|
56
|
+
scope: str
|
|
57
|
+
limitations: list[str]
|
|
58
|
+
coverage: list[Feature]
|
|
59
|
+
modifications: list[Modification] = field(default_factory=list)
|
|
60
|
+
versions: dict[str, str] = field(default_factory=dict)
|
|
61
|
+
evidence: dict = field(default_factory=dict)
|
|
62
|
+
fault_class: str | None = None
|
|
63
|
+
|
|
64
|
+
def __post_init__(self):
|
|
65
|
+
self.status = Status(self.status)
|
|
66
|
+
if not all(isinstance(v, str) and v for v in (self.check, self.reason, self.scope)):
|
|
67
|
+
raise ValueError("Results require check, reason and scope")
|
|
68
|
+
if not self.coverage or not all(isinstance(v, Feature) for v in self.coverage):
|
|
69
|
+
raise ValueError("Coverage is mandatory")
|
|
70
|
+
if not self.limitations or not all(isinstance(v, str) and v for v in self.limitations):
|
|
71
|
+
raise ValueError("Limitations are mandatory")
|
|
72
|
+
if not isinstance(self.evidence, dict) or not isinstance(self.versions, dict):
|
|
73
|
+
raise ValueError("Evidence and versions must be objects")
|
|
74
|
+
if not all(isinstance(k, str) and isinstance(v, str) for k, v in self.versions.items()):
|
|
75
|
+
raise ValueError("Versions must map names to strings")
|
|
76
|
+
if not all(isinstance(m, Modification) for m in self.modifications):
|
|
77
|
+
raise ValueError("Invalid modification ledger")
|
|
78
|
+
if self.fault_class is not None and not isinstance(self.fault_class, str):
|
|
79
|
+
raise ValueError("Invalid fault class")
|
|
80
|
+
json.dumps(asdict(self), allow_nan=False)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Report:
|
|
85
|
+
results: list[Result]
|
|
86
|
+
schema_version: str = "1.0"
|
|
87
|
+
|
|
88
|
+
def __post_init__(self):
|
|
89
|
+
if self.schema_version != "1.0":
|
|
90
|
+
raise ValueError("Unsupported report schema version")
|
|
91
|
+
if not isinstance(self.results, list) or not all(isinstance(r, Result) for r in self.results):
|
|
92
|
+
raise ValueError("Invalid results")
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def counts(self):
|
|
96
|
+
return {s.value: sum(r.status == s for r in self.results) for s in Status}
|
|
97
|
+
|
|
98
|
+
def exit_code(self, strict=False):
|
|
99
|
+
return int(not self.results or any(r.status == Status.FAIL or
|
|
100
|
+
(strict and r.status != Status.PASS) for r in self.results))
|
|
101
|
+
|
|
102
|
+
def dumps(self):
|
|
103
|
+
return json.dumps(asdict(self), indent=2, allow_nan=False) + "\n"
|
|
104
|
+
|
|
105
|
+
def write(self, path):
|
|
106
|
+
Path(path).write_text(self.dumps(), encoding="utf-8")
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def loads(cls, text):
|
|
110
|
+
def pairs(items):
|
|
111
|
+
result = {}
|
|
112
|
+
for k, v in items:
|
|
113
|
+
if k in result:
|
|
114
|
+
raise ValueError(f"Duplicate JSON key: {k}")
|
|
115
|
+
result[k] = v
|
|
116
|
+
return result
|
|
117
|
+
def constant(value):
|
|
118
|
+
raise ValueError(f"Nonfinite JSON number: {value}")
|
|
119
|
+
data = json.loads(text, object_pairs_hook=pairs, parse_constant=constant)
|
|
120
|
+
if not isinstance(data, dict) or set(data) != {"schema_version", "results"}:
|
|
121
|
+
raise ValueError("Invalid report fields")
|
|
122
|
+
if data['schema_version'] != '1.0' or not isinstance(data['results'], list):
|
|
123
|
+
raise ValueError("Unsupported report schema or results")
|
|
124
|
+
results = []
|
|
125
|
+
for row in data['results']:
|
|
126
|
+
if not isinstance(row, dict) or set(row) != set(Result.__dataclass_fields__):
|
|
127
|
+
raise ValueError("Invalid result fields")
|
|
128
|
+
row = dict(row)
|
|
129
|
+
for key, constructor in (("coverage", Feature), ("modifications", Modification)):
|
|
130
|
+
if not isinstance(row[key], list):
|
|
131
|
+
raise ValueError(f"{key} must be a list")
|
|
132
|
+
entries = []
|
|
133
|
+
for entry in row[key]:
|
|
134
|
+
if not isinstance(entry, dict) or set(entry) != set(constructor.__dataclass_fields__):
|
|
135
|
+
raise ValueError(f"Invalid {key} fields")
|
|
136
|
+
entries.append(constructor(**entry))
|
|
137
|
+
row[key] = entries
|
|
138
|
+
results.append(Result(**row))
|
|
139
|
+
return cls(results, data['schema_version'])
|
|
140
|
+
|
|
141
|
+
def summary(self):
|
|
142
|
+
lines = []
|
|
143
|
+
for r in self.results:
|
|
144
|
+
lines.append(f"{r.status.value} {r.check}: {r.reason} [{r.scope}]")
|
|
145
|
+
lines.extend(f" Limit: {v}" for v in r.limitations)
|
|
146
|
+
lines.extend(f" Coverage: {f.name}: {f.status} (present={f.present}): {f.note}" for f in r.coverage)
|
|
147
|
+
lines.extend(f" Modified: {m.name}: {m.before} -> {m.after}: {m.reason}" for m in r.modifications)
|
|
148
|
+
lines.append("Counts: " + ", ".join(f"{k}={v}" for k, v in self.counts.items()))
|
|
149
|
+
if not self.results or any(r.status not in (Status.PASS, Status.FAIL) for r in self.results):
|
|
150
|
+
lines.append("WARNING: some requested behaviour was not assessed; this is not an all-clear.")
|
|
151
|
+
return "\n".join(lines)
|
simaudit/worker.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from contextlib import redirect_stdout
|
|
3
|
+
import sys
|
|
4
|
+
from .release import collect, load_factory
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
request = json.load(sys.stdin)
|
|
9
|
+
with redirect_stdout(sys.stderr):
|
|
10
|
+
report = collect(load_factory(request['factory']),request['states'],request['actions'],request['steps'])
|
|
11
|
+
sys.stdout.write(report.dumps())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if __name__ == '__main__':
|
|
15
|
+
main()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "Please cite the two method papers below when using simaudit. Paper authorship is recorded separately from software authorship."
|
|
3
|
+
title: "simaudit: scoped diagnostics for simulators"
|
|
4
|
+
type: software
|
|
5
|
+
version: 0.1.0a1
|
|
6
|
+
date-released: 2026-11-14
|
|
7
|
+
repository-code: "https://github.com/TesfayZ/simaudit"
|
|
8
|
+
keywords:
|
|
9
|
+
- reinforcement learning
|
|
10
|
+
- simulator validation
|
|
11
|
+
- MuJoCo
|
|
12
|
+
- Gymnasium
|
|
13
|
+
- numerical verification
|
|
14
|
+
abstract: "Scoped, reproducible diagnostics for user-supplied simulators: parameter-assignment consistency, integrator-order convergence, release comparison and observation consistency, with explicit statements of what was not assessed."
|
|
15
|
+
license: MIT
|
|
16
|
+
authors:
|
|
17
|
+
- family-names: Gebrekidan
|
|
18
|
+
given-names: Tesfay Zemuy
|
|
19
|
+
references:
|
|
20
|
+
- type: article
|
|
21
|
+
title: "Diagnosing Model and Discretisation Errors in Reinforcement Learning Simulators Using Invariants"
|
|
22
|
+
authors:
|
|
23
|
+
- family-names: Gebrekidan
|
|
24
|
+
given-names: Tesfay Zemuy
|
|
25
|
+
year: 2026
|
|
26
|
+
doi: 10.5281/zenodo.24114609
|
|
27
|
+
url: "https://zenodo.org/records/24114609"
|
|
28
|
+
- type: article
|
|
29
|
+
title: "Diagnosing Faults in Reinforcement Learning Simulators with Canonical Polynomial Invariants"
|
|
30
|
+
authors:
|
|
31
|
+
- family-names: Gebrekidan
|
|
32
|
+
given-names: Tesfay Zemuy
|
|
33
|
+
- family-names: Gebrerufael
|
|
34
|
+
given-names: Hadush Hailu
|
|
35
|
+
year: 2026
|
|
36
|
+
doi: 10.48550/arXiv.2609.13194
|
|
37
|
+
url: "https://arxiv.org/abs/2609.13194v2"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Method provenance and reuse
|
|
2
|
+
|
|
3
|
+
The package uses two source papers, with their full authorship preserved:
|
|
4
|
+
|
|
5
|
+
1. **Tesfay Zemuy Gebrekidan.** *Diagnosing Model and Discretisation Errors in
|
|
6
|
+
Reinforcement Learning Simulators Using Invariants* (2026).
|
|
7
|
+
[Zenodo record](https://zenodo.org/records/24114609).
|
|
8
|
+
Source repository: [`dafd`](https://github.com/TesfayZ/dafd). Assignment consistency, refinement diagnostics,
|
|
9
|
+
native integrator controls and precision/coverage limitations inform this package.
|
|
10
|
+
2. **Tesfay Zemuy Gebrekidan and Hadush Hailu Gebrerufael.** *Diagnosing Faults in
|
|
11
|
+
Reinforcement Learning Simulators with Canonical Polynomial Invariants* (2026).
|
|
12
|
+
[arXiv:2609.13194v2](https://arxiv.org/abs/2609.13194v2).
|
|
13
|
+
Source repository: [`algebraicRLtest`](https://github.com/TesfayZ/algebraicRLtest). Paired release comparisons, positive
|
|
14
|
+
controls and observation consistency inform this package. Its algebraic
|
|
15
|
+
screening/localisation method is not implemented in this alpha.
|
|
16
|
+
|
|
17
|
+
Both source repositories have MIT licences, copyright 2026 Tesfay Zemuy Gebrekidan.
|
|
18
|
+
That software licence notice does not replace the papers' scholarly authorship.
|
|
19
|
+
The MIT notice is retained in LICENSE. `validation/known_cases.py` adapts the
|
|
20
|
+
minimal heavy-top geometry from `dafd/benchmark_quaternion_mechanism.py`; its
|
|
21
|
+
collision masks are explicitly changed for the conservative test. The small
|
|
22
|
+
checks are new adapter-based implementations of the documented methods. No
|
|
23
|
+
research runner, paper prose, dataset, model or complete source module is vendored.
|
|
24
|
+
Source file hashes inspected during development appear in evidence/reuse_sources.json.
|
|
25
|
+
|
|
26
|
+
Runtime dependencies are imported, not bundled: NumPy (BSD-3-Clause), Gymnasium
|
|
27
|
+
(MIT, optional), MuJoCo (Apache-2.0, optional), pytest (MIT, optional). A stable
|
|
28
|
+
release still needs a full resolved dependency licence inventory, including
|
|
29
|
+
transitive dependencies. This list does not claim that audit has been completed.
|
|
30
|
+
|
|
31
|
+
Official interfaces consulted: [MuJoCo API](https://mujoco.readthedocs.io/en/stable/APIreference/APIfunctions.html)
|
|
32
|
+
and [pytest plugin documentation](https://docs.pytest.org/en/stable/how-to/writing_plugins.html).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
@misc{gebrekidan2026dafd,
|
|
2
|
+
author = {Gebrekidan, Tesfay Zemuy},
|
|
3
|
+
title = {Diagnosing Model and Discretisation Errors in Reinforcement Learning Simulators Using Invariants},
|
|
4
|
+
year = {2026},
|
|
5
|
+
doi = {10.5281/zenodo.24114609},
|
|
6
|
+
url = {https://zenodo.org/records/24114609}
|
|
7
|
+
}
|
|
8
|
+
@misc{gebrekidanGebrerufael2026canonical,
|
|
9
|
+
author = {Gebrekidan, Tesfay Zemuy and Gebrerufael, Hadush Hailu},
|
|
10
|
+
title = {Diagnosing Faults in Reinforcement Learning Simulators with Canonical Polynomial Invariants},
|
|
11
|
+
year = {2026},
|
|
12
|
+
eprint = {2609.13194},
|
|
13
|
+
archivePrefix = {arXiv},
|
|
14
|
+
primaryClass = {cs.LG},
|
|
15
|
+
doi = {10.48550/arXiv.2609.13194},
|
|
16
|
+
url = {https://arxiv.org/abs/2609.13194v2}
|
|
17
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simaudit
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Scoped, reproducible diagnostics for user-supplied simulators
|
|
5
|
+
Author: Tesfay Zemuy Gebrekidan
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/TesfayZ/simaudit
|
|
8
|
+
Project-URL: Issues, https://github.com/TesfayZ/simaudit/issues
|
|
9
|
+
Project-URL: Documentation, https://github.com/TesfayZ/simaudit#readme
|
|
10
|
+
Keywords: reinforcement-learning,simulator,verification,mujoco,gymnasium,testing
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Classifier: Framework :: Pytest
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy>=1.24
|
|
24
|
+
Provides-Extra: gymnasium
|
|
25
|
+
Requires-Dist: gymnasium>=1.0; extra == "gymnasium"
|
|
26
|
+
Provides-Extra: mujoco
|
|
27
|
+
Requires-Dist: mujoco>=3.2; extra == "mujoco"
|
|
28
|
+
Requires-Dist: gymnasium>=1.0; extra == "mujoco"
|
|
29
|
+
Provides-Extra: pytest
|
|
30
|
+
Requires-Dist: pytest>=8; extra == "pytest"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
33
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
34
|
+
Requires-Dist: gymnasium>=1.0; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# simaudit
|
|
38
|
+
|
|
39
|
+
Installable diagnostics for **your own simulator**, with explicit limits on what
|
|
40
|
+
was assessed. Supply a small Python adapter; NumPy is the only core dependency.
|
|
41
|
+
Gymnasium and MuJoCo are optional examples. Version **0.1.0a1** is an alpha,
|
|
42
|
+
not a generally calibrated simulator-certification service.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Pre-release: pip skips alpha versions unless you ask for them.
|
|
48
|
+
python -m pip install --pre simaudit
|
|
49
|
+
# Optional adapters and pytest integration:
|
|
50
|
+
python -m pip install --pre 'simaudit[gymnasium,mujoco,pytest]'
|
|
51
|
+
# Or from a source checkout:
|
|
52
|
+
python -m pip install .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Python 3.11 or newer (CI matrix 3.11-3.14; local validation on 3.14.6 and 3.12.3). Nothing downloads, runs experiments, or imports an optional
|
|
56
|
+
simulator on package import. For development: `python -m pip install '.[dev]'`.
|
|
57
|
+
|
|
58
|
+
## Test your own simulator
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import numpy as np
|
|
62
|
+
from simaudit import Adapter, Feature, Report, check_order
|
|
63
|
+
|
|
64
|
+
# Wrap your simulator so each call restores every dependency of its next step.
|
|
65
|
+
def step(state, action, dt):
|
|
66
|
+
return state + dt * (state + action)
|
|
67
|
+
|
|
68
|
+
def native_reference(state, action, dt):
|
|
69
|
+
return np.asarray([(1 + dt) * state[0] + dt * action])
|
|
70
|
+
|
|
71
|
+
adapter = Adapter(
|
|
72
|
+
name="my simulator", step_fn=step, native_step_fn=native_reference,
|
|
73
|
+
features=[Feature("dynamics", "tested", "Smooth scalar ODE, no hidden state", True)],
|
|
74
|
+
smooth=True, declared_order=1, dt=0.2,
|
|
75
|
+
)
|
|
76
|
+
result = check_order(adapter, [[0.5], [1.0], [1.5]], [0, 0, 0])
|
|
77
|
+
report = Report([result])
|
|
78
|
+
print(report.summary())
|
|
79
|
+
report.write("audit.json")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The native reference should call the original simulator without the adapter
|
|
83
|
+
transformation being tested. A user-provided reference and inventory are trusted
|
|
84
|
+
inputs, not automatically verified specifications. See [the adapter guide](docs/ADAPTERS.md)
|
|
85
|
+
for mutable simulators, noise, arbitrary state metrics, assignment factories,
|
|
86
|
+
observation maps and capability requirements.
|
|
87
|
+
|
|
88
|
+
The CLI loads a trusted local `module:function` returning an adapter. From this
|
|
89
|
+
directory, run the included NumPy-only example:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
PYTHONPATH=examples simaudit order --factory custom_simulator:factory \
|
|
93
|
+
--probes examples/probes.json --strict --json audit.json
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Python factories execute code with your Python process's privileges. Probe files
|
|
97
|
+
contain only `states` and matching `actions`. The same interface works with your
|
|
98
|
+
own importable adapter module. `python -m simaudit` is equivalent to `simaudit`.
|
|
99
|
+
|
|
100
|
+
## Four independent checks
|
|
101
|
+
|
|
102
|
+
| Check | Python API | Needs | What it reports |
|
|
103
|
+
|---|---|---|---|
|
|
104
|
+
| Parameter assignment | `check_assignment` | Replay, native reference, assignment factory | Baseline/direct/fresh comparison; no-op, stale, consistent or unresolved |
|
|
105
|
+
| Numerical order | `check_order` | Replay, native reference, timestep, declared order, smoothness, distance | Stable refinement slopes, below-declaration order, or inability to diagnose |
|
|
106
|
+
| Release/variant comparison | `compare_releases` | Factory and two supplied Python interpreters | First-step differences on identical inputs; later differences recorded separately |
|
|
107
|
+
| Observation consistency | `check_observation` | Replay, native reference, observed and reference observation maps | Differences above the precision/noise floor |
|
|
108
|
+
|
|
109
|
+
The package doesn't implement the papers' energy-fingerprint classifier,
|
|
110
|
+
algebraic localisation, parameter identification or automatic equation diagnosis.
|
|
111
|
+
A numerical-order pass cannot establish that equations are correct.
|
|
112
|
+
|
|
113
|
+
## Results and limitations
|
|
114
|
+
|
|
115
|
+
Every result contains **PASS, FAIL, ABSTAIN, SKIPPED, PARTIAL or BLOWUP**, a reason,
|
|
116
|
+
scope, limitations, mandatory feature coverage, modifications, versions and raw
|
|
117
|
+
probe measurements. Limitations and untested features are printed beside results.
|
|
118
|
+
|
|
119
|
+
- Float32 or a declared noise floor blocks a healthy consistency verdict in
|
|
120
|
+
checks A, B and D. A and D can still flag discrepancies above their floors.
|
|
121
|
+
- Missing capabilities return `SKIPPED`; unsupported smoothness/features give
|
|
122
|
+
`PARTIAL`; unresolved or unstable slopes give `ABSTAIN`.
|
|
123
|
+
- Nonfinite trajectories or detected engine resets give `BLOWUP`.
|
|
124
|
+
- A failed adapter replay/native-step self-test blocks downstream diagnosis.
|
|
125
|
+
- `PASS` covers the supplied probes under the declared conditions only. A wrong
|
|
126
|
+
reference, shared implementation error or unreported hidden state can escape it.
|
|
127
|
+
|
|
128
|
+
By default `FAIL` exits 1; other nonpasses print a warning and exit 0. `--strict`
|
|
129
|
+
exits 1 on **any** nonpass. Configuration errors exit 2. Empty reports are never
|
|
130
|
+
all-clear. [Report contract](docs/REPORTS.md).
|
|
131
|
+
|
|
132
|
+
## Optional adapters and releases
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
simaudit assignment --factory simaudit.adapters.cartpole:cartpole \
|
|
136
|
+
--probes examples/cartpole-probes.json --parameter masscart --value 2 --json cartpole.json
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
This deliberately detects the paper's assignment discrepancy and exits 1. The
|
|
140
|
+
fresh CartPole reference explicitly rebuilds dependent constants; it does not
|
|
141
|
+
assert that Gymnasium promises arbitrary attribute mutation.
|
|
142
|
+
|
|
143
|
+
`mujoco_xml(xml)` supplies native replay for smooth autonomous rigid models and
|
|
144
|
+
rejects unsupported features. `reacher()` supplies a cache-consistency comparison
|
|
145
|
+
against the shipped observation map after `mj_forward`. Both live in
|
|
146
|
+
`simaudit.adapters.mujoco`; imports are lazy. Custom adapters are not subject to
|
|
147
|
+
these built-in models' limitations but must declare their own.
|
|
148
|
+
|
|
149
|
+
For a release pair, create two separate environments yourself, with NumPy and
|
|
150
|
+
the simulator versions you want, then supply their Python paths:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
simaudit release --factory simaudit.adapters.cartpole:cartpole \
|
|
154
|
+
--probes examples/cartpole-probes.json --python-a /path/a/bin/python \
|
|
155
|
+
--python-b /path/b/bin/python --strict --json releases.json
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The worker runs the current package source in each interpreter. The package does
|
|
159
|
+
not install or upgrade either environment. Your custom factory module must also
|
|
160
|
+
be importable there. Missing versions remain untested. A changed first step means
|
|
161
|
+
compatibility changed, not necessarily that either release is wrong.
|
|
162
|
+
|
|
163
|
+
## pytest and verification
|
|
164
|
+
|
|
165
|
+
Install the `pytest` extra to use the auto-discovered `simaudit_check` fixture:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
def test_simulator(simaudit_check):
|
|
169
|
+
simaudit_check(check_order(adapter, [[1.0]], [0]))
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`FAIL` fails the test; other nonpasses skip with their original status and full
|
|
173
|
+
reason. `pytest --simaudit-strict --simaudit-json results.json` fails the session
|
|
174
|
+
on any nonpass and preserves the separate audit counts. Reporting is serial;
|
|
175
|
+
pytest-xdist aggregation is not supported.
|
|
176
|
+
|
|
177
|
+
Run software tests with `python -m pytest`. Build with
|
|
178
|
+
`python -m build`. See [validation results](VALIDATION.md),
|
|
179
|
+
[prospective paper-case protocol](validation/PROTOCOL.md), and
|
|
180
|
+
[requirements and acceptance criteria](docs/REQUIREMENTS.md). These are small package tests
|
|
181
|
+
and selected reproductions, not a general false-alarm guarantee.
|
|
182
|
+
|
|
183
|
+
## Cite the two method papers
|
|
184
|
+
|
|
185
|
+
1. **Tesfay Zemuy Gebrekidan** (2026). *Diagnosing Model and Discretisation Errors
|
|
186
|
+
in Reinforcement Learning Simulators Using Invariants*.
|
|
187
|
+
[Zenodo](https://zenodo.org/records/24114609).
|
|
188
|
+
2. **Tesfay Zemuy Gebrekidan and Hadush Hailu Gebrerufael** (2026).
|
|
189
|
+
*Diagnosing Faults in Reinforcement Learning Simulators with Canonical
|
|
190
|
+
Polynomial Invariants*. [arXiv:2609.13194v2](https://arxiv.org/abs/2609.13194v2).
|
|
191
|
+
|
|
192
|
+
[CITATION.cff](CITATION.cff), [BibTeX](references.bib), and
|
|
193
|
+
[reuse and licence notices](THIRD_PARTY.md) preserve the attribution. MIT licensed.
|
|
194
|
+
|
|
195
|
+
## What it has found so far
|
|
196
|
+
|
|
197
|
+
From [VALIDATION.md](VALIDATION.md); each row is scoped to the supplied probes.
|
|
198
|
+
|
|
199
|
+
| Case | Result |
|
|
200
|
+
|---|---|
|
|
201
|
+
| Gymnasium CartPole `masscart`/`masspole`/`length` assignment | FAIL (no-op / stale / partly stale) vs a freshly constructed reference; `gravity` PASS |
|
|
202
|
+
| MuJoCo 3.16.0 and 3.17.0, RK4 heavy top on a ball joint | FAIL: 23/24 probes below declared order 4 (1/24 ABSTAIN), median slope 1.96 |
|
|
203
|
+
| MuJoCo **4.0.1**, same model and probes | PASS 24/24, median slope 3.92 |
|
|
204
|
+
| Same model with three hinges (control) | PASS in every tested engine |
|
|
205
|
+
| Reacher post-step observation vs refreshed map | FAIL, largest discrepancy 2.4e-6 (3.16.0, 3.17.0 and 4.0.1) |
|
|
206
|
+
|
|
207
|
+
MuJoCo 4.0.0 itself was not installed or tested; no result is inferred for it.
|
|
208
|
+
|
|
209
|
+
## Roadmap
|
|
210
|
+
|
|
211
|
+
Not implemented in 0.1.0a1, and not claimed anywhere in this package:
|
|
212
|
+
|
|
213
|
+
- **0.2:** energy-refinement fingerprint (model-versus-discretisation label), a
|
|
214
|
+
decision table combining checks, and a one-parameter fit, each only with its
|
|
215
|
+
negative-control suite passing.
|
|
216
|
+
- **0.3:** algebraic invariant screening behind an optional extra.
|
|
217
|
+
- Other classic-control assignment adapters, vector environments, and shipped
|
|
218
|
+
Ant/Humanoid with contacts and actuation.
|
|
219
|
+
|
|
220
|
+
## Documentation
|
|
221
|
+
|
|
222
|
+
| File | Purpose |
|
|
223
|
+
|---|---|
|
|
224
|
+
| [docs/ADAPTERS.md](docs/ADAPTERS.md) | Writing an adapter and declaring its features |
|
|
225
|
+
| [docs/REPORTS.md](docs/REPORTS.md) | Report schema, statuses and JSON round trip |
|
|
226
|
+
| [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) | Acceptance criteria recorded before validation |
|
|
227
|
+
| [VALIDATION.md](VALIDATION.md) | What was actually run, and the limits of those runs |
|
|
228
|
+
| [validation/PROTOCOL.md](validation/PROTOCOL.md) | Paper-case protocol and scripts |
|
|
229
|
+
| [evidence/README.md](evidence/README.md) | Archived result files and the hash receipt |
|
|
230
|
+
| [CHANGELOG.md](CHANGELOG.md) | Release history |
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
simaudit/__init__.py,sha256=72i1TW3JwB8nZQngmorSyqPkWs7DNHRvlJmZXM2JEso,510
|
|
2
|
+
simaudit/__main__.py,sha256=ee5vE0xcUZM3fPmxicvcw-IJXkm6nOwxARREHBWpF8Q,47
|
|
3
|
+
simaudit/checks.py,sha256=tJ2xOXeXknYaDjHAvWHXVDeVyjn8tWjXiXj8z3kobo8,12885
|
|
4
|
+
simaudit/cli.py,sha256=cbLvgDMHoSfsQ449SbRhfnE2nWmOpfr_vPM7CkmQYuw,2880
|
|
5
|
+
simaudit/pytest_plugin.py,sha256=ms_dBO4Zhq6qNUl-Huc_SFtqHHicmHVnPXBWZEGKCKs,1782
|
|
6
|
+
simaudit/release.py,sha256=ldZ9Sud5pFkjfcBeZTK4Kzxi634DLUP87XE7BzWZzMc,6089
|
|
7
|
+
simaudit/report.py,sha256=aQsiucHHURd987gBunbg0Gtx74W1rRP6PsoyOMn2DrE,6214
|
|
8
|
+
simaudit/worker.py,sha256=WUBkt-12weqJGzjRP7j61X6-w3r4Nk8X6xUgfZh8kQo,382
|
|
9
|
+
simaudit/adapters/__init__.py,sha256=SFI5Twz2WGyfIhvqg8152hgtpa7xsny9sEg-olr7fhg,95
|
|
10
|
+
simaudit/adapters/base.py,sha256=fODSkr5UEyrAptJ8_vtrkS09B3-b2iqs0ZvZ3TTdUzs,2757
|
|
11
|
+
simaudit/adapters/cartpole.py,sha256=1Dy6fMgQ4RJgH16VmMRzQxVldzt36CF2O2CtQgRddcs,3140
|
|
12
|
+
simaudit/adapters/mujoco.py,sha256=jLt38BdpdkodfZcp228kybHBotUvaX13q8Ma-6i_MAY,5884
|
|
13
|
+
simaudit-0.1.0a1.data/data/share/simaudit/CITATION.cff,sha256=nshC08u1HzbTEzzL8sxk_E-V875RMeysWNP8VQviX0Y,1438
|
|
14
|
+
simaudit-0.1.0a1.data/data/share/simaudit/THIRD_PARTY.md,sha256=e9W7z0-ZMhYk7NxAG_HGnYYDsH7NDM4GDJgfLhY5cG8,2174
|
|
15
|
+
simaudit-0.1.0a1.data/data/share/simaudit/references.bib,sha256=ToEgAMwAe0WVhB-DZ6xRR-SKk2WpzEEGu3LXKDn7Qko,670
|
|
16
|
+
simaudit-0.1.0a1.dist-info/licenses/LICENSE,sha256=9iJOTYwCvmsf7jyd4zbJfz7wgnnnJDsMzvxyMlQJgdg,1080
|
|
17
|
+
simaudit-0.1.0a1.dist-info/METADATA,sha256=G8J_WNLF0_Y5jhgr5ImfupSK1PB6Vu9bMP-bf_D11Ag,10768
|
|
18
|
+
simaudit-0.1.0a1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
19
|
+
simaudit-0.1.0a1.dist-info/entry_points.txt,sha256=JdH2N1T89mxJsBo6GLRsuRhlpgrgvyzl5m3Bc-ATWpw,93
|
|
20
|
+
simaudit-0.1.0a1.dist-info/top_level.txt,sha256=plyGr4pRC9fpDN4ehfAjOq5jGkne-D7RLhEBHhEmqqE,9
|
|
21
|
+
simaudit-0.1.0a1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tesfay Zemuy Gebrekidan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simaudit
|