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 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,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ from .base import Adapter, Blowup, Unsupported
2
+
3
+ __all__ = ["Adapter", "Blowup", "Unsupported"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ simaudit = simaudit.cli:main
3
+
4
+ [pytest11]
5
+ simaudit = simaudit.pytest_plugin
@@ -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