Repository navigation
Comparing changes
Open a pull request
base repository: SciSharp/Gym.NET
base: master
head repository: SciSharp/Gym.NET
compare: feat/numsharp-master-migration
- 13 commits
- 147 files changed
- 1 contributor
Commits on Sep 25, 2026
-
build(deps): consume NumSharp master through refs/NumSharp
Step 1 of docs/plans/NUMSHARP_MIGRATION_PLAN.md §13: Gym.NET drops NumSharp.Lite 0.1.12 and compiles against NumSharp master, built from the refs/NumSharp submodule (pinned at 93967990, still the upstream master tip on 2026-09-25). NuGet 0.70.0 cannot be used: DType "Stage B" (c916c579, 68a02258) landed after it (plan §3.2). No int -> long change and no seeding change yet; those are steps 2 and 3. Build plumbing (plan §4) - New eng/NumSharp.props, imported at the END of src/Gym, src/Gym.Environments and tests/Gym.Tests (the NumSharp.Lite PackageReference is gone from both src projects): - NumSharpSource=Submodule (default) -> ProjectReference to refs/NumSharp/src/NumSharp.Core; NumSharpSource=Package -> PackageReference NumSharp at NumSharpPackageVersion (deliberately empty until a release contains Stage B). - NumSharp's ownership analyzer (tools/NumSharp.Build.Analyzer) is referenced explicitly in Submodule mode, because NumSharp.Core's own reference to it is PrivateAssets="all". - Two additions beyond the plan's §4.2 snippet, both found while reviewing it: 1. Packing guard. In Submodule mode the NumSharp.Core ProjectReference packs as a dependency on NuGet "NumSharp 0.70.0" (master's VersionPrefix), exactly the version without Stage B; verified by packing with the escape hatch: the nuspec lists NumSharp 0.70.0. Both src projects set GeneratePackageOnBuild=true, so every build would have produced such a package. Submodule mode now sets GeneratePackageOnBuild=false and an explicit `dotnet pack` fails with an explanatory error (target GymNetBlockSubmodulePack, BeforeTargets=GenerateNuspec). NumSharpAllowSubmodulePack=true lifts both for local experiments. This is why the import sits at the end of each project: the override must be evaluated after the project's own property. The viewer projects keep packing; their packages depend on Gym.NET/Gym.NET.Environments only, not on NumSharp. 2. ReferenceNDArrayAnalyzer=false drops the analyzer reference, the same switch NumSharp.Core honours. A file-based app (dotnet run x.cs with #:project) forces its TFM onto the whole reference graph during restore, and the netstandard2.0 analyzer then fails with NETSDK1005. - Configuration guards, each replacing a flood of CS0246 or a bare NuGet error with one message: missing submodule (the plan's GymNetRequireNumSharpSubmodule), an unknown NumSharpSource value, and Package mode without NumSharpPackageVersion. - CLAUDE.md §3.3 and docs/guides/development_and_testing.md: initialize with `git submodule update --init refs/Gymnasium refs/NumSharp`, not --recursive (which would clone NumSharp's numpy, pocketfft, OpenBLAS, onnxruntime, ... submodules). AGENTS.md and GEMINI.md only point at CLAUDE.md. Core repairs (plan §6; line numbers of Appendix A were still valid: every .cs file was byte-identical to the audited 2639785) - Space: `Type DType` -> `DType DType` (NumSharp's descriptor; DType -> Type is explicit, so `Type t = space.DType` needs `(Type)` or `.type`). Seed stays int until step 2. - Box: - constructors take `DType dType`; `Box(NDArray, NDArray, ...)` validates through the new RequireSameShape (null checks plus Gymnasium's ValueError "Box low.shape and high.shape don't match, low.shape=(3,), high.shape=(2,)", box.py:179) instead of base(null) (CS0037) and a Debug.Assert that vanished in Release. The shape is rebuilt from a copy of the dimensions, so a view passed as a bound cannot carry strides or an offset into the space. - `Box(float, float, Shape, ...)`: np.full(shape, value, dtype) replaces (low + np.zeros(shape, dType)).astype(dType); the dead Equals(shape, null) guard is gone (Shape is a struct, trap 7). - CheckBounded compares against scalar infinities (was np.full(±inf) arrays, whose argument order flipped: CS1503). Integer boxes construct now (B-07). - IsBounded uses np.all; the Lite 0-d Any/All helpers are deleted. - Sample: one path for every rank (the Lite 0-d branch is deleted), `mask is not null`, categories counted with np.count_nonzero, draw order unchanged (unbounded, low-bounded, upper-bounded, bounded), and the bounded branch computes NumPy's float64 lower + range * next_double() through random_sample(n) (trap 11; verified against numpy/random/mtrand.pyx and distributions.c at v2.4.2). The integer check is DType.kind in i/u/b, Gymnasium's test; the former `DType == np.@byte` silently stopped meaning uint8 because np.@byte is int8 in NumSharp (trap 4). B-05 fixed. B-08 and B-09 stay for P2 and are documented on the method. - Contains returns false on a shape mismatch, then short-circuits two np.all reductions (B-06 fixed; it threw for every non-0-d box). - Equals compares shape, dtype and np.array_equal (NDArray.Equals broadcasts and throws for unbroadcastable shapes, trap 3); GetHashCode hashes shape and dtype (NDArray.GetHashCode throws, trap 2). - Discrete: `DType dType`; Sample uses `mask is not null` (B-01: every call threw), draws choice(np.nonzero(mask == 1)[0]) among the valid actions (B-04), and returns a fresh 0-d int64 array (randint with dtype int64: NumSharp defaults to int32, trap 10; same stream). - Step: Equals compares observations by shape, dtype and np.array_equal without broadcasting; GetHashCode hashes the observation's shape (CS0266 and trap 2). - LunarLanderEnv: the 12 array -> scalar sites. Draws go through TakeDouble/TakeInt32, which convert the 0-d result explicitly and dispose it; dispersion is divided in float64 and narrowed once; the continuous branch reads both throttles once as float32 scalars (Math.Clamp/Math.Abs replace five 0-d NDArray expressions per step); shaping uses (float)Math.Sqrt, which is correctly rounded like float32 np.sqrt. - Code that had to change is written in its final form, including `using` on the arrays it creates. The type-level ownership (IDisposable spaces, Env.Dispose(bool), [NDBorrowed], CartPole's state) remains step 4. NDW sites after this commit: 5, all of them step 4's: NDW016 Box and Step, NDW017 CartPoleEnv.state, NDW012 in the two tests the plan names (CartPole drops Reset()'s result; the LunarLander PID temporary). The plan measured 23 before any scoping. Tests - BoxTest: the 0-d sample needs an explicit (float) cast. New tests pin B-05/B-06 (vector sample shape, dtype, bounds, membership), all four sampling categories in one box, Contains on wrong shape/out of bounds, integer boxes (B-07), flooring for int8/int16/int32/ int64 (a truncating cast could never reach the lower bound -5), shape- and dtype-sensitive equality without throwing, hashing, the ValueError message, and mask rejection. Integer boxes use float literals: `new Box(0, 5, shape, np.int32)` is ambiguous between the seed and random_state overloads (CS0121, confirmed with a scratch compile), documented on both. - LunarLanderEnvironment: all eight baselines of plan §9.2, asserted exactly (steps and the float32 bits of the total reward) in every configuration; the #if DEBUG guard is gone (B-37). New data-driven Run_NullEnv_Baseline covers seeds 0, 1, 42, 1000 x discrete/ continuous. Seed 1000 discrete moves from Lite's 245 steps / 35.515747 to 230 / 40.93515 (0x4223bd98) because NumSharp master's RandomState is NumPy's MT19937. The "no seed" runs still seed with 0; assertions key on seed.HasValue so a seed-0 baseline can be asserted. Verification (Windows 11 x64, .NET SDK 10.0.101, runtimes 8.0.29/10.0.1, AVALONIA_TELEMETRY_OPTOUT=1) - Solution builds with 0 errors in Debug and Release. Gym.NET's own warning families are the 11 of plan Appendix C.4, plus the 5 NDW sites above. - Headless tests (everything except the WinForm/Avalonia viewer tests): 26/26 in Debug and Release on net8.0-windows and net10.0-windows. All eight baselines match the plan's recorded values to the bit. - Guards exercised: missing submodule (-p:NumSharpRoot=<bogus>), NumSharpSource=Foo, NumSharpSource=Package without a version, `dotnet pack` (blocked), pack with NumSharpAllowSubmodulePack=true (produces a nupkg that depends on NumSharp 0.70.0, which is what the guard prevents), ReferenceNDArrayAnalyzer=false (builds, 0 NDW). - No NumSharp.Lite.dll in any test output; the Lite workaround patterns of plan §10 are gone from src/ (only comments explaining them remain), which is F1's exit criterion.Configuration menu - View commit details
-
Copy full SHA for 801f123 - Browse repository at this point
Copy the full SHA 801f123View commit details -
refactor: long indices, actions and seeds
Step 2 of docs/plans/NUMSHARP_MIGRATION_PLAN.md §13: Gym.NET's own API adopts NumSharp's int64 indexing wherever a value crosses into or out of NumSharp, or is a Python int that NumPy stores as int64 (plan §5). Decisions taken as the plan recommends: M2 (Discrete elements are long now, the (n) shape stays until P2) and M3 (seeds are long now). The §5.2 rows about ownership (IDisposable spaces, Env.Dispose(bool), [NDBorrowed] Step.Observation) stay with step 4, so this commit is only about widths and conversions. Public API (binary break; source keeps compiling where C# widens implicitly: Seed(5), new Discrete(2), space.DType == typeof(float)) - Space.Seed, IEnv.Seed, Env.Seed: (int) -> (long). Overrides must change their signature. - Discrete: N and Start are long; constructor (long n, DType dType = null, long seed = -1, long start = 0, NumPyRandom random_state = null); the default dtype is int64 (Gymnasium's default; it was float32 under NumSharp.Lite). - Box constructors: `int seed` -> `long seed`. - IVecEnv/VecEnv.Step, VecEnv.StepAsync: (int action) -> (long action); IVecEnv/VecEnv.Seed: (int) -> (long) and (int[]) -> (long[]). They still send one action to every sub-env (B-33, P6 deletes the type). New: Gym.Utils.LegacySeeding.RandomState(long) - The one factory for seeded legacy generators, bit-identical to numpy.random.RandomState(seed). np.random.RandomState(int) cannot take the valid seeds above int.MaxValue and validates nothing: NumPyRandom(int) -> new MT19937(seed) -> (uint)seed, so RandomState(-1) silently seeded 4294967295. The factory reseeds a fresh instance through seed(long), which accepts [0, 2^32 - 1] and raises NumPy's ValueError("Seed must be between 0 and 2**32 - 1") otherwise. - Every seeded path uses it: Box/Discrete constructors and Seed, CartPoleEnv.Seed, the LunarLander test. Behavior change: a constructor seed of -1 still means "unseeded", but any other negative seed now throws instead of wrapping (-2 used to become 4294967294). - FromEntropy (per-instance unseeded generators) is step 3. New: Discrete.ToInt64(object) / TryToInt64(object, out long) (plan §6.4, trap 1) - Converts every integer width, bool, enum members of any underlying type, and 0-d integer NDArrays (the 0-d int64 array Sample returns). A uint64 above long.MaxValue throws OverflowException instead of wrapping (NumSharp's own (long) turns 0-d uint64 ulong.MaxValue into -1). Everything else throws ArgumentException from ToInt64 / returns false from TryToInt64. One deliberate addition to the plan's snippet: `bool` converts (True = 1), because Python's bool is an int subclass and Gymnasium's Discrete.contains accepts it; a 0-d *boolean* array is still rejected (np.bool_ is not np.integer). - Used by CartPoleEnv.Step, LunarLanderEnv.Step and Env<TAction>.Step/StepAsync. Each of them cast `(int)action` or `(TAction)(object)action`, which unbox exactly and threw InvalidCastException for a long (what VecEnv.Step(long) sends), for a sampled action, and for widths that differ from an enum's underlying type. Env<TAction> now maps through Enum.ToObject(typeof(TAction), Discrete.ToInt64(action)); StepAsync still converts inside the scheduled work. Discrete membership (gymnasium/spaces/discrete.py:198 @ 9e04324) - Contains(long) honours Start (B-02: Discrete(3, start: 5) accepted 0 and rejected 5 and 7) and computes Start + N in Int128, because Gymnasium compares unbounded Python ints. - Contains(object) goes through TryToInt64 and returns false for anything that is not an integer, and for a uint64 that does not fit int64, as Gymnasium does; under Lite it threw NotSupportedException for everything but a boxed int. Contains(Enum) shares that path; its former (int)(object)x threw for enums not backed by int. - Not ported yet (P2): Gymnasium's np.can_cast(x.dtype, self.dtype) check. Envs - CartPoleEnv: long action through Discrete.ToInt64 (documented: still only Debug.Assert- validated, B-14; the observation still aliases the state, B-13, until step 4); Seed(long) through LegacySeeding. - LunarLanderEnv: _wind_idx/_torque_idx are long (TakeInt32 -> TakeInt64); i_action is long through Discrete.ToInt64; Seed(long) still reseeds the env's generator IN PLACE via seed(long): the env hands the same generator to its action space, so an in-place reseed keeps reseeding both, as before (a replacement would silently decouple them). Documented, including that the constructor-time wind draws are not redrawn (B-18). Tests (48 headless, was 26) - DiscreteTests: ToInt64 over int, long, short, sbyte, byte, ushort, uint, ulong, bool, enums backed by byte/ulong/int; 0-d int32/int64/uint64/uint8 arrays and a sampled action; OverflowException for ulong, 0-d uint64 and a ulong enum above long.MaxValue; ArgumentException for double, float, decimal, string, null, a 1-D array, 0-d float64 and 0-d bool arrays; Contains with a start (B-02), for non-integers and overflow (false), and beyond int64 without wrapping (Discrete(long.MaxValue, start: 1)); Sample is 0-d int64 inside [start, start + n) (B-01); a 2^40-element space samples in range; masked sampling reaches exactly the allowed elements and an all-zero mask returns start (B-04); seeding at construction and later replays the same stream. - SeedTests: 2^31 + 5 and 2^32 - 1 are accepted by LegacySeeding, Discrete, Box, CartPole and LunarLander; 2^31 + 5 has its own reproducible stream (differs from seed 5); -1, 2^32 and long.MinValue raise ValueError with NumPy's message everywhere; constructor seed -1 means unseeded, -2 throws. - CartPoleTests: steps with ActionSpace.Sample() (trap 1); the same transition for 1 given as int, long, byte, short, uint, a 0-d array and true; ArgumentException for 0.5; consecutive observations are distinct objects; ObservationSpace.Contains(Reset()) (shape and bounds only: float64 observation vs float32 space, B-13). - EnvTests: Env<TAction> with a long-backed enum converts int, long, byte, a 0-d array, a member and a sampled action; DummyVecEnv steps with a long action and takes seeds above int.MaxValue. - New test code disposes the arrays it drops (Reset() results, which are copies), so the NDW count stays at step 1's 5 sites, all of them step 4's. Step observations are never disposed: CartPole's still alias its state. Findings recorded in the code - NumSharp's legacy randint diverges from NumPy for high in [2^31, 2^32] with int64 dtype: NumSharp switches to its 64-bit masked path above int.MaxValue while NumPy keeps its 32-bit one up to 2^32. Measured with seed 0, five draws: identical for high = 4, 2^31 - 1, 2^32 + 1 and 2^40; different for 2^31, 3e9 and 2^32. Documented at Discrete.Sample; a new upstream NumSharp item. - Env.StepAsync (and VecEnv.StepAsync) cannot run on this 32-logical-CPU machine: constructing DistributedScheduler.Default throws ArgumentException from DistributedThread._validateProcessorAffinity, which computes 1 << ProcessorCount in an int (0 after 32 shifts, so every affinity is "too large"). Present on master, unrelated to NumSharp; the threading is deleted in P1 (B-34). The StepAsync conversion is therefore not tested (the note is in EnvTests). Verification (Windows 11 x64, .NET SDK 10.0.101, AVALONIA_TELEMETRY_OPTOUT=1) - 0 errors in Debug and Release; Gym.NET's own warning families unchanged (plan Appendix C.4); NDW sites unchanged at 5. - Headless tests: 48/48 in Debug and Release on net8.0-windows and net10.0-windows. The eight LunarLander baselines are unchanged (steps and float32 reward bits).Configuration menu - View commit details
-
Copy full SHA for c110cbc - Browse repository at this point
Copy the full SHA c110cbcView commit details -
fix(rng): per-instance entropy-seeded generators
Step 3 of docs/plans/NUMSHARP_MIGRATION_PLAN.md §13, decision M4 as recommended: every env and space created without a seed or a generator now draws from a generator of its own, seeded from the OS CSPRNG. Nothing in src/ touches np.random or np.random.RandomState() any more except the LegacySeeding factory itself. The two defects replaced (plan §7 traps 5 and 6, §9.3, §9.4) - Box, Discrete and LunarLanderEnv fell back to the process-wide np.random. One space's draws shifted every other unseeded space's stream (B-29: "a second env perturbs the first"), and NumSharp's MT19937 takes no lock, so two threads drawing from it throw IndexOutOfRangeException (trial: 2 threads x 2,000,000 draws). - CartPoleEnv fell back to np.random.RandomState(), which NumSharp seeds with (uint)Environment.TickCount: every instance created within the same millisecond replays one stream (trial: 1,000 of 1,000 back-to-back pairs identical; NumPy's are independent). A naive per-instance `?? np.random.RandomState()` fix for the spaces would have given an env's action and observation spaces, or a whole batch of envs, the same draws. Change - New LegacySeeding.FromEntropy(): four OS-random 32-bit words (RandomNumberGenerator.Fill) through seed(uint[]), i.e. MT19937 init_by_array, which rebuilds the whole 624-word state (so the TickCount seeding of the fresh instance does not survive; checked in MT19937.SeedByArray). NumPy's RandomState(None) instead fills the key from SeedSequence() entropy (numpy/random/_mt19937.pyx:130 at v2.4.2); neither is reproducible, only independence matters (upstream item U2 would make NumSharp's own unseeded constructor do this). - Box (both bounds constructors; the int overloads forward), Discrete: `random_state ?? LegacySeeding.FromEntropy()`. LunarLanderEnv: the null branch of its fallback. CartPoleEnv: `randomState ?? LegacySeeding.FromEntropy()`. Seeded paths are unchanged (step 2), and a generator passed in is still used as given, so LunarLander keeps sharing its random_state with its action space. - Docs updated: Space.Seed now states that seeding np.random has no effect on spaces; constructor docs say what null means; LunarLanderEnv.Seed (still in place) no longer reseeds the global when unseeded, because the env owns its generator; CartPoleEnv's constructor is documented (B-15's lazy seeding stays for P4). Behavior change for users (release notes): np.random.seed(x) no longer makes unseeded spaces or envs deterministic; space.Seed(x) / env.Seed(x) or a seed/random_state argument does, as in Gymnasium. NumPy golden (plan §9.1, Appendix D.2) - tools/parity/legacy_rng.py: the plan's recorder, verbatim, plus a docstring on regenerating the golden. Run with Python 3.12.12 and NumPy 2.4.2 (the reference stack, as installed here). - tests/Gym.Tests/Parity/legacy_rng.golden.txt: its output, 602 lines (10 patterns x 7 seeds: 0, 1, 42, 1000, 2^31 - 1, 2^31 + 5, 2^32 - 1), LF endings, embedded in the test assembly as Gym.Tests.Parity.legacy_rng.golden.txt. - LegacyRngParityTests.Draws_MatchNumPyRandomStateBitForBit replays the patterns through Gym.NET: CartPoleEnv.Reset (real env), Discrete(4, seed) Sample and masked Sample (real space), Box(-1, 1, (3,), float32, seed) Sample (real space, compared as float32 bits), an unbounded and a low-bounded float64 Box on one generator (normal, then exponential), and LunarLander's constructor/reset/step draw order on a LegacySeeding generator. Result: 602 of 602 identical. - LegacyRngParityTests.LunarLander_ConsumesExactlyTheRecordedDrawPattern closes the gap of that hand replay: after constructing the real LunarLanderEnv, Reset and 20 steps, its generator's MT19937 state (key and position) equals that of a generator that made the golden's lunar.* draws by hand, for all 7 seeds. A draw added to or removed from the env would fail it. Other new tests - EntropySeedingTests: 64 FromEntropy generators made back to back all differ; unseeded Discrete/Box instances each own a generator and none is np.random (probe subclasses read the protected RandomState); two unseeded spaces made back to back draw different streams; np.random.seed(123) before creating two unseeded spaces does not make them equal, while space.Seed(123) does; two unseeded CartPoles reset to different states. - ConcurrencyTests: two unseeded LunarLanders and two unseeded CartPoles step on four threads released together by a Barrier, sampling their action and observation spaces every step; two unseeded Discrete spaces sampled 250,000 times each on two threads. Documented as smoke tests: a race only fails when it interleaves badly. - Mutation checks (run, then reverted): making FromEntropy TickCount-seeded fails 4 of the 5 entropy tests (back-to-back streams, spaces, the global-seed test, envs); putting `?? np.random` back into Discrete fails UnseededSpaces_EachOwnTheirGenerator and the global-seed test deterministically, while the parallel smoke tests still pass, as expected. Ownership: new code disposes what it drops; the NDW count stays at the 5 step-4 sites (NDW016 Box and Step, NDW017 CartPoleEnv.state, NDW012 in the two pre-existing tests). Tests never dispose a CartPole step observation, which still aliases the env's state (B-13) until step 4. Verification (Windows 11 x64, .NET SDK 10.0.101, runtimes 8.0.29/10.0.1, AVALONIA_TELEMETRY_OPTOUT=1) - 0 errors in Debug and Release; Gym.NET's own warning families unchanged (plan Appendix C.4). - Headless tests: 57/57 in Debug and Release on net8.0-windows and net10.0-windows, including the eight LunarLander baselines (unchanged since step 1: they pass their own random_state). - Viewer tests, run once (Debug, net8.0-windows; they open WinForms and Avalonia windows): 9/9, including both seed-1000 consistency checks with a live viewer and two Avalonia instances in parallel.
Configuration menu - View commit details
-
Copy full SHA for c141717 - Browse repository at this point
Copy the full SHA c141717View commit details -
build(deps): declare the regular NumSharp PackageReference next to th…
…e NumSharp.props import Maintainer request (2026-09-25): besides `<Import Project="..\..\eng\NumSharp.props" />`, each project carries the regular NuGet reference to NumSharp itself, next to the import, instead of eng/NumSharp.props declaring it. Projects (src/Gym, src/Gym.Environments, tests/Gym.Tests) - New, immediately before the import: <ItemGroup> <PackageReference Include="NumSharp" Version="0.70.0" /> </ItemGroup> 0.70.0 is the newest NumSharp on nuget.org (checked 2026-09-25: 0.60.0, 0.70.0 and older prereleases). tests/Gym.Tests declares it too, so the ownership analyzer covers the tests in Package mode: NuGet does not flow a package's analyzers to projects that only reference it through another project (default PrivateAssets = contentfiles;analyzers;build). - The comments next to it say what happens in each mode and why the import must stay last. eng/NumSharp.props - No longer declares any NumSharp PackageReference, and NumSharpPackageVersion is gone (the version now lives in the projects, where Dependabot and `nuget outdated` see it). - NumSharpSource=Submodule (still the default): `<PackageReference Remove="NumSharp" />`, then the ProjectReference to refs/NumSharp/src/NumSharp.Core and the analyzer ProjectReference, as before. The Remove only sees items declared before it, one more reason the import comes last. - NumSharpSource=Package: the declared reference is used as it stands. - The declared reference is captured before the Remove (`@(PackageReference->WithMetadataValue('Identity', 'NumSharp'))`), and the new GymNetRequireDeclaredNumSharpPackage guard fails a project that imports the props without declaring it, in either mode, with a message saying where to put it. It replaces GymNetRequireNumSharpPackageVersion, which had nothing left to check. - The packing guard's message now says to pack in Package mode once the declared reference names a release with Stage B. - Header corrected with a measurement: against NuGet 0.70.0 the migrated code fails with FIVE errors, not the plan's four: four CS0121 (astype(Type, bool) vs astype(NPTypeCode, bool) for a DType argument: Box.cs 113 and 114 in the NDArray constructor, 283 and 285 in Sample, whose floor path casts in a separate statement since the scoped rewrite) and one CS1061 (Type has no kind: Discrete.TryToInt64). docs/plans/NUMSHARP_MIGRATION_PLAN.md §3.2 counted the trial's code, which had one astype call fewer. Evaluation checked with `dotnet msbuild -getItem`: - Submodule: PackageReference = SixLabors.ImageSharp, JetBrains.Annotations, SixLabors.ImageSharp.Drawing (NumSharp removed); ProjectReference = NumSharp.Core, NumSharp.Build.Analyzer; captured declaration = NumSharp 0.70.0. - Package: PackageReference adds NumSharp 0.70.0; no ProjectReference. Verification (Windows 11 x64, .NET SDK 10.0.101, AVALONIA_TELEMETRY_OPTOUT=1) - Default (Submodule) mode: 0 errors in Debug and Release; headless tests 57/57 in Debug and Release on net8.0-windows and net10.0-windows. The test output's NumSharp.dll is byte-identical (md5) to refs/NumSharp's own Debug build, so the swap really builds from source. - Package mode: restores NumSharp 0.70.0 from nuget.org and fails with the five errors above, exactly the Stage-B call sites, so Package mode will build once the declared version is a release that contains Stage B. - Guards: a scratch project importing the props without the reference fails in both modes with the new message; a missing submodule (-p:NumSharpRoot=<bogus>), NumSharpSource=Foo and an explicit `dotnet pack` still fail with their single explanatory errors; builds produce no Gym.NET or Gym.NET.Environments package. Follow-up for the migration's docs step: the plan's §4.2 snippet and §4.4 flip procedure still describe NumSharpPackageVersion; the flip is now "raise the declared Version in the three projects to the first Stage-B release, then make Package the default".Configuration menu - View commit details
-
Copy full SHA for 684ca1d - Browse repository at this point
Copy the full SHA 684ca1dView commit details -
fix(lunarlander): reject continuous actions with fewer than two throt…
…tles before the unchecked reads Found by the Gymnasium parity audit probe (tools/parity/audit/gymnet_side.cs), which steps the continuous LunarLander with ActionSpace.Sample(): the process terminated with "Assertion failed. index < Count, Memory corruption expected." at UnmanagedStorage.GetSingle <- LunarLanderEnv.Step. Cause (introduced by 801f123): the continuous branch reads its throttles with c_action.GetSingle(0) and GetSingle(1). NumSharp's GetSingle/GetDouble(params long[]) index the buffer through Shape.GetOffset with no bounds check; ArraySlice guards the access with a Debug.Assert only. The continuous action space still samples 0-d actions (defect B-20), so: - NumSharp built in Debug: the assertion fails and terminates the process; - NumSharp built in Release: GetSingle(1) reads 4 bytes past the action's buffer and the step runs on a garbage lateral throttle. Before 801f123 the NDArray indexer (c_action[1], bounds-checked) threw IndexError instead. Fix: after clipping, a 0-d action throws IndexError("too many indices for array: array is 0-dimensional, but 1 were indexed") and an action whose first axis is shorter than 2 throws IndexError("index 1 is out of bounds for axis 0 with size N"), NumPy's messages for action[0] and action[1]. Every action that passes has both reads in bounds. B-20 itself (the 0-d action space) stays for P8; the Step doc now says which actions throw. Test: LunarLanderEnvironment.Step_ContinuousActionWithFewerThanTwoThrottles_ThrowsIndexError steps a sampled 0-d action and a one-element action and checks both messages. Verification: 0 errors in Debug and Release; headless tests 58/58 in Debug and Release on net8.0-windows and net10.0-windows. Upstream note (NumSharp): GetSingle/GetDouble/... (params long[] indices) are unchecked in Release; an out-of-range index reads outside the buffer. They could validate like the indexer does, or be documented as unchecked accessors.
Configuration menu - View commit details
-
Copy full SHA for 2a1b827 - Browse repository at this point
Copy the full SHA 2a1b827View commit details -
docs(audit): measure byte parity with Gymnasium and add reproducible …
…probes Adds docs/plans/GYMNASIUM_PARITY_AUDIT.md, a measured answer to "how close is Gym.NET to byte-perfect with Gymnasium", taken at 2a1b827 (NumSharp migration steps 1-3) against refs/Gymnasium @ 9e04324 run live from source, on the port plan's reference stack: Windows 11 x64, Python 3.12.12, NumPy 2.4.2, pygame-ce 2.5.8 (SDL 2.32.10), Box2D 2.3.10, .NET SDK 10.0.101. (pygame-ce 2.5.8 and Box2D 2.3.10 were installed into this machine's Python from their cp312 win_amd64 wheels for the audit.) Verdict: nothing Gymnasium produces is byte-identical in Gym.NET yet, for any seed, while the layer underneath is byte-exact and a projection shows that the spaces and CartPole close completely once the planned ports land. Measured (seeds 0, 1, 42, 1000; "seeds identical" = every value of the case matches): - Seeding: first 8 random() doubles, 0 / 4 seeds today (legacy MT19937 vs Gymnasium's Generator(PCG64(SeedSequence))); 4 / 4 on NumSharp's Generator. - Discrete (plain, masked, start) and Box (bounded float32, unbounded, low-bounded, int32): 0 / 4 seeds in every case today; 4 / 4 in every case when Gymnasium's formulas run on NumSharp's Generator (start + integers(n); choice(where(mask)); float64 low + (high - low) * random(); standard normal; exponential + low; high + 1 / floor / clip). Today's scattered single-value matches in the integer cases are chance (3 to 6 outcomes). - CartPole: reset 0 / 4 seeds (and float64 vs float32 observations); dynamics from Gymnasium's own initial state, 500 open-loop steps per seed: 0 / 2,000 steps bit-identical today (first divergence at step 1 on every seed; float32 constants, B-11), 2,000 / 2,000 for a float64 transcription of cartpole.py:164 with Math.Cos/Sin, reward and terminated included (confirms the port plan's A.7). Observation bounds differ (float.MaxValue vs inf, B-12). - CartPole frames from identical states: 0 / 12 byte-identical, 2,729-2,981 of 240,000 pixels differ (1.14-1.24%), max channel delta 255 (B-16; matches A.9). - LunarLander (Gymnasium Box2D v3 vs Gym.NET Aether v2 bounds): reset observations 0 / 4 seeds; Gymnasium's heuristic lands (rewards 256-306) where Gym.NET's port of it scores -46 to 47; random play leaves Gym.NET's own observation space in 131-326 of 2,000 steps (6.5-16.3%, B-19; Gymnasium 0 / 2,000); continuous Step(Sample()) raises IndexError (B-20). - L1: 46 public Gym.NET types; the port plan's 258-name inventory re-classified for the migration only: 3 match, 14 diverge (Box and Discrete move from broken), 2 broken (LunarLander, LunarLanderContinuous), 218 missing, 21 N/A. L2: 0 of Gymnasium's 490 test functions (102 files) are ported. The report maps every gap to its cause and to the port-plan phase that closes it (F2 seeding, P2 spaces, P4 classic_control, P1/P3 contract and TimeLimit, F5/P7 rasterizer, F6/P8 Box2D), and lists what this audit found: the LunarLander unchecked-read bug (fixed in 2a1b827), NumSharp's Generator.choice(array) returning a one-element array instead of a scalar, NumSharp's unchecked GetSingle/GetDouble in Release, B-19's new measurement, and the earlier randint and DistributedScheduler findings. Probes (tools/parity/audit/), reproduced by the report's §6: - gymnasium_side.py: runs the cases in Gymnasium; writes gymnasium.txt ("<case> <index> <value>", float64 as 16 hex digits, float32 as 8), CartPole rgb_array frames as raw (400, 600, 3) bytes, and the states they were rendered from. - gymnet_side.cs: a file-based app over src/Gym.Environments; writes gymnet.txt (Gym.NET today, with Gymnasium's CartPole states injected by reflection so dynamics and frames start equal), projection.txt (Gymnasium's algorithms on NumSharp's Generator plus the float64 CartPole step), Gym.NET's frames and its public type list. Run with --no-cache: a file-based app otherwise reuses its last build after the referenced projects change. - compare.py: scores the recordings (whole-sequence identity per seed, CartPole step identity and first divergence, per-frame pixel differences) and prints the report's tables.
Configuration menu - View commit details
-
Copy full SHA for ede57bd - Browse repository at this point
Copy the full SHA ede57bdView commit details -
feat(rng): seed spaces and envs with Gymnasium's Generator(PCG64(Seed…
…Sequence)) Gym.NET's envs and spaces now draw from the generator Gymnasium uses, NumPy's Generator(PCG64(SeedSequence(seed))), through NumSharp's port of it (PCG64, SeedSequence and Generator have been in NumSharp since e868d8ae, part of v0.70.0). Before, they drew from NumPy's legacy RandomState (MT19937), a different generator family, so no seeded Gym.NET stream could match Gymnasium (the parity audit measured 0 / 4 seeds identical in every space case). Now Gym.NET's seeded spaces and CartPole reset match Gymnasium's bit for bit: 783 of 783 values over 9 seeds up to 2^40. This is the port plan's F2 (docs/plans/GYMNASIUM_PORT_PLAN.md §5.2) for what Gym.NET has today. Seeding (port of gymnasium/utils/seeding.py @ 9e04324) - New Gym.Utils.Seeding.NpRandom(BigInteger? seed = null) -> (Generator Rng, BigInteger Seed), mirroring seeding.np_random: validate, SeedSequence(seed), report seed_seq.entropy, wrap PCG64(seed_seq) in a Generator. BigInteger because Gymnasium's seeds are Python ints of any size and the seed it reports for an unseeded generator is 128-bit entropy; every C# integer converts implicitly, null means OS entropy. NumSharp keeps an unseeded sequence's entropy as its four uint32 words; it is reported as the integer they encode (least significant word first), as NumPy does, and passing it back rebuilds the stream (except when the top word is 0, 1 in 2^32). A single BigInteger? parameter because long? and BigInteger overloads are ambiguous for a long argument (CS0121, neither converts implicitly to the other). - New Gym.Exceptions.Error, the port of gymnasium.error.Error (the superclass of every error Gymnasium raises itself; subclasses come with P1). A negative seed raises it with Gymnasium's message "Seed must be greater or equal to zero, actual value: -1". The seed range is now Gymnasium's: any non-negative seed; the legacy [0, 2^32 - 1] ceiling and NumPy's ValueError are gone. Constructor seed -1 still means "unseeded". - LegacySeeding (legacy RandomState factory) is deleted; nothing uses NumPyRandom any more. Spaces - Box and Discrete draw from `protected Generator NpRandom` (was `protected NumPyRandom RandomState`; Gymnasium's np_random), constructor `random_state` parameters are Generator, and Seed(long) replaces the space's generator with Seeding.NpRandom(seed).Rng, as Space.seed does. - Discrete.Sample makes Gymnasium's calls: start + integers(n, dtype=int64), and start + choice(np.where(mask == 1)[0]) for a mask. NumSharp's Generator.choice(array) without a size returns a one-element array where NumPy returns a scalar, so the value is read from it (SingleValue). NumSharp's Generator.integers is exact above 2^31 as well (the golden's Discrete(3_000_000_000) and Discrete(2**40) cases), unlike its legacy randint. - Box.Sample is now a port of gymnasium/spaces/box.py:404, which also removes two defects the touched draw lines carried: unbounded elements draw a STANDARD normal (was normal(0.5, 1), B-08); boxes that are not floating-point shift high by one (high.astype(int64) + 1) so integer samples reach high (B-09), both for the bounded uniform and the upper-bounded exponential offset. The new CastSample ports box.py:459: floor for kinds i/u/b, clip integer dtypes to np.iinfo's range (int64 with Gymnasium's margin of 2, which still rounds to +-2^63 in float64), cast, and clip int64 again to the bounds; bool is floored but not clipped (np.issubdtype(bool, integer) is False). The bounded draw stays low + (high - low) * random(n) in float64, NumPy's Generator.uniform arithmetic (NumSharp's uniform takes scalar bounds only). Envs - CartPoleEnv: Generator field and constructor parameter, unseeded fallback Seeding.NpRandom(), Seed(long) -> Seeding.NpRandom(seed).Rng. Reset already made Gymnasium's call, uniform(-0.05, 0.05, size=4), so Seed(s) + Reset() now starts exactly where Gymnasium's reset(seed=s) starts (golden: 9 / 9 seeds, state bits). The step dynamics still use float32 constants (B-11, P4). - LunarLanderEnv: Generator throughout; the wind and turbulence phases draw integers(-9999, 9999), Gymnasium's call (still in the constructor, B-18). Seed(long) keeps its in-place semantics, so an action space that shares the env's generator is still reseeded with it: NumPy's Generator has no reseed method, so the state of a freshly seeded PCG64 (state, increment and the buffered 32-bit half, NumPy's bit_generator.state) is copied into the shared instance; a caller-supplied generator on another bit generator is replaced instead. LunarLander stays incomparable with Gymnasium's values (Aether physics, draw order B-17/B-18). - Seed docs across IEnv/Env/IVecEnv/VecEnv/Space and both envs now state Gymnasium's contract. Tests (62 headless + 9 viewer) - GymnasiumRngParityTests replaces LegacyRngParityTests (and tools/parity/gymnasium_rng.py plus its golden replace tools/parity/legacy_rng.py and the NumPy-legacy golden): - SpacesAndCartPoleReset_MatchGymnasiumBitForBit: 783 values recorded from Gymnasium itself (PYTHONPATH=refs/Gymnasium, NumPy 2.4.2) for seeds 0, 1, 42, 1000, 2^31 - 1, 2^31 + 5, 2^32 - 1, 2^32, 2^40: seeding.np_random's seed and first 8 random() values; Discrete plain, masked, start=5, n=3e9, n=2^40; Box bounded float32, all four bound categories in one box, unbounded, low-bounded, upper-bounded, int32 [0, 5], int8 [-5, 5], uint8 [0, 255]; CartPole reset state and float32 observation. All identical. - LunarLander_ConsumesExactlyItsDrawPattern: after construction, reset and 20 steps the real env's PCG64 state (state, inc, has_uint32, uinteger) equals a hand replay of its draws. - SeedTests rewritten for Gymnasium's contract: ports of test_valid_seeds (0, 1, plus seeds up to long.MaxValue and 2^100 + 7, reported back unchanged) and test_invalid_seeds (-1 and long.MinValue raise Error with the message); an unseeded generator reports 128-bit entropy that rebuilds its stream; large seeds work everywhere and replay; negative seeds throw everywhere; constructor -1 unseeded, -2 throws; LunarLander's Seed reseeds a shared action space in place. - EntropySeedingTests on the new generator (probe subclasses read NpRandom); BoxTest gains IntegerBox_SamplesReachTheUpperBound (B-09). - LunarLanderEnvironment's eight baselines re-recorded on the new stream (steps and float32 reward bits; identical in Debug and Release, checked on net8.0 and net10.0): seed 0 / 1 / 42 / 1000 discrete 192 / 499 / 200 / 209 steps, continuous 156 / 156 / 184 / 167 steps. The legacy stream's values (seed 1000 discrete 230 / 40.93515) are kept in the doc comment's history. Also: tools/parity/audit/gymnet_side.cs uses Seeding (so the audit's "today" column now draws from Gymnasium's generator), and CLAUDE.md's Env-contract line says Seed(long) with Gymnasium's seeding. Verification (Windows 11 x64, .NET SDK 10.0.101, AVALONIA_TELEMETRY_OPTOUT=1) - 0 errors in Debug and Release; Gym.NET's warning families unchanged; NDW sites unchanged (the 5 step-4 ones). - Headless tests 62/62 in Debug and Release on net8.0-windows and net10.0-windows; viewer tests 9/9 once (Debug, net8.0-windows), including both seed-1000 consistency checks. Not part of this commit: the concurrent session's live-parity work in the same working tree (tests/Gym.Tests.Parity, tools/parity setup scripts and manifest, its eng/NumSharp.props and .gitignore edits).Configuration menu - View commit details
-
Copy full SHA for b51689d - Browse repository at this point
Copy the full SHA b51689dView commit details -
docs(audit): re-measure Gymnasium parity after the PCG64 seeding upgrade
Brings docs/plans/GYMNASIUM_PARITY_AUDIT.md up to b51689d, the commit that moved Gym.NET's envs and spaces onto Gymnasium's generator, Generator(PCG64(SeedSequence(seed))). All numbers were re-run live against Gymnasium @ 9e04324 with the committed probes (tools/parity/audit/: gymnasium_side.py, gymnet_side.cs with dotnet run --no-cache, compare.py) on the same reference stack (Windows 11 x64, Python 3.12.12, NumPy 2.4.2, pygame-ce 2.5.8, Box2D 2.3.10, .NET SDK 10.0.101). The first measurement (ede57bd, taken at 2a1b827 on the legacy MT19937 stream) is kept as a history section. Verdict, rewritten - Was: "Nothing Gymnasium produces is byte-identical in Gym.NET yet." - Now: Gym.NET's seeded draws are byte-identical to Gymnasium's. Discrete and Box samples and CartPole's reset state equal Gymnasium's bit for bit: 4 of 4 seeds in every audit case, and 783 of 783 values over 9 seeds up to 2^40 in the committed golden (GymnasiumRngParityTests). These are the first L3 results against Gymnasium itself; before the upgrade every case was 0 / 4. - What is still not byte-identical, each with what closes it: CartPole observations (float64 hand-out, B-13, a cast in P4), CartPole dynamics (float32 constants, B-11; the float64 transcription stays 2,000 / 2,000), frames (rasterizer, F5/P7), LunarLander (Aether and v2 geometry, a Box2D port F6/P8), and the API contract (216 of 258 names missing, P1-P6). Measurements that changed (seeds 0, 1, 42, 1000) - Seeding: first 8 random() doubles 32 / 32 values, 4 / 4 seeds (was 0 / 32, 0 / 4). - Spaces: Discrete plain, masked and start=5; Box bounded float32, unbounded, low-bounded and int32: 4 / 4 seeds each (was 0 / 4 each, with chance matches of single integer draws). The section now lists what still differs outside the sampled values: Discrete shape (n,) vs () (B-03), 0-d NDArray vs NumPy scalar, Gymnasium's validation and errors (B-10), probability masks, Discrete sampling in int64 rather than the space's dtype (Gymnasium passes dtype=self.dtype.type, discrete.py:196), and Box.contains lacking np.can_cast (box.py:493). - CartPole reset: internal state 4 / 4 (was 0 / 4); the observation stays 0 / 4 because it is handed out as float64 where Gymnasium hands out float32. - LunarLander (still Aether, still unrelated to Gymnasium's Box2D episodes): heuristic episodes on the new stream are discrete 192 / 499 / 200 / 209 steps (rewards 52.2 / 12.4 / 19.3 / -0.5), continuous 156 / 156 / 184 / 167 steps (46.9 / 39.6 / 36.4 / 15.9), matching the re-recorded unit-test baselines of b51689d. Random play leaves the env's own observation space in 221, 185, 474 and 156 of 2,000 steps (7.8-23.7%; it was 6.5-16.3% on the legacy stream), so B-19 is unchanged in kind. - Unchanged: CartPole dynamics 0 / 2,000 (first divergence at step 1, max drift 3.94e-6, same termination step), frames 0 / 12 (2,729-2,981 of 240,000 pixels, 1.14-1.24%, max delta 255). L1 re-tally (258 in-scope names, port plan section 2.1) - seeding.np_random: Missing -> Matches (Gym.Utils.Seeding.NpRandom: same validation, message, generator and reported seed). - error.Error: Missing -> Diverges (Gym.Exceptions.Error exists, but the older Gym.NET exceptions do not derive from it yet). - Tally 4 match / 15 diverge / 2 broken / 216 missing / 21 N/A (was 3 / 14 / 2 / 218 / 21). - Public types 47 (was 46: LegacySeeding removed; Seeding and Error added). Other edits - Section 3.1 states the seed range precisely: Seeding.NpRandom takes any non-negative BigInteger; the envs' and spaces' Seed(long) any non-negative long; the legacy 2^32 - 1 ceiling is gone. It records the NumSharp unseeded-entropy item (four uint32 words instead of NumPy's integer). - Section 4: the seeded-stream gap is marked done in b51689d; what stays open in F2 is lazy seeding, np_random_seed and Generator.geometric (spaces.Sequence; missing on NumSharp master). - Section 5 (findings) gains the entropy-representation item and states that NumSharp's legacy randint divergence in [2^31, 2^32] does not affect Generator.integers (the golden's n = 3e9 and 2^40 cases match); finding 2 (choice returning a one-element array) notes Discrete.Sample's single-element read. - Section 7 (reproduction, was 6) adds the golden's recorder command (PYTHONPATH=refs/Gymnasium python tools/parity/gymnasium_rng.py | tr -d '\r' > ...golden.txt); it imports neither pygame nor Box2D, verified against cartpole.py's function-local pygame imports. Checked before committing: the report was listed as modified at this session's start although this session had not edited it; both sessions' transcripts show no write to it since ede57bd, and the pre-rewrite reads (lines 1-40 here, lines 1-120 by the concurrent session) equal HEAD, so the rewrite replaced no one else's uncommitted edits. Not part of this commit: the concurrent session's live-parity work in the same working tree (tests/Gym.Tests.Parity, tools/parity/manifest.json, requirements.txt, setup_venv.ps1/.sh, and its eng/NumSharp.props and .gitignore edits).
Configuration menu - View commit details
-
Copy full SHA for cf0dd77 - Browse repository at this point
Copy the full SHA cf0dd77View commit details -
test(parity): live Gymnasium parity suite over pythonnet (tests/Gym.T…
…ests.Parity) Builds the live half of docs/plans/GYM_PARITY_INTEROP_TESTING.md, modelled on NumSharp's test/NumSharp.Tests.Interop. The test process embeds CPython 3.12 through pythonnet 3.0.5, imports Gymnasium from the refs/Gymnasium submodule (9e04324) and compares Gym.NET with it byte for byte and pixel for pixel: dtypes, shapes and C-order bytes of arrays, IEEE bits of floats, info keys in order, exceptions (type mapped per port plan section 3.4, message verbatim), the PCG64 generator state after every call, and rgb_array pixels. Nothing is serialized in between: Gymnasium computes its answer in the same process, for the same seed and the same actions. Result (Windows 11 x64, .NET SDK 10.0.101; Debug and Release; net8.0 and net10.0): 130 cells, 103 gated and all passing, 27 [OpenBugs] each failing at its documented defect and nothing else. Byte-identical to Gymnasium today (gated): - Seeding.NpRandom against seeding.np_random: the reported seed and the PCG64 state for 9 seeds (0 ... long.MaxValue) and for 2^100 + 7; negative seeds raise Gym.Exceptions.Error with Gymnasium's message; an unseeded generator's 128-bit seed rebuilds the same generator in NumPy. - The 15 draw patterns of the port plan's Appendix A.6, value for value, with the generator state after each. - Discrete: samples (n up to 2^40, negative and positive start), masked samples, an all-zero mask (start, no draw), membership over 15 candidates, n/start/dtype, repr of a plain space. - Box: bounds, bounded_below/above, dtype, shape and is_bounded for 8 boxes (every bound category of Box.sample, float32/float64, int8/int32, uint8); 5 samples and the generator state for each box under 4 seeds; membership. - CartPole: a test-local float64 transcription of cartpole.py matches 4 seeds x 500 steps and through termination (the live form of the port plan's A.7); Gym.NET's action space. LunarLander: Gym.NET's discrete action space. Open gaps (27 [OpenBugs], each asserting Gymnasium's answer, so the port turns them green unchanged): - CartPole reset differs only by the missing float32 cast (B-13): after b51689d the state and the generator already match, and the report says "casting dotnet to <f4 would make them identical". Episodes (B-11, B-13); one step from an injected state moves all four state elements at step 1 (B-11); frames differ in 2,573 / 2,747 / 2,659 pixels at A.9's three states, exactly A.9's counts (B-16); observation-space bounds float.MaxValue instead of inf (B-12). - LunarLander: the continuous action space is 0-d (B-20); v2 observation bounds (B-19); heuristic episodes diverge at the reset (F6, P8, B-17, B-19). - Spaces: can_cast in contains (P2, both spaces); a list as a Box point (P2); Discrete's shape () (B-03); repr with a start (P2); Discrete(0) validation (B-10). - New, found by this suite: Box(float low, float high, ...) stores a float64 box's bounds through float, so Box(-0.1, 0.1, (2,), float64).low is -0.10000000149011612 where Gymnasium has -0.1 (107374182 ulps apart). Harness (tests/Gym.Tests.Parity): - Session/: GymPythonSession (one engine per process, [AssemblyInitialize]/[AssemblyCleanup]); NumSharp's interpreter discovery plus the parity venv (GYMNET_PARITY_VENV, else tools/parity/.venv; a configured venv that is broken fails and never falls back to another Python); a stack check against tools/parity/manifest.json (Gymnasium commit and version, Python 3.12.*, NumPy, pygame-ce, SDL, Box2D) plus structural checks (gymnasium imported from the submodule, the submodule unmodified per git status, hash randomization off); ParityTestBase with NumSharp's LiveExports/LiveImports leak gate after every test. - The determinism knobs (PYTHONHASHSEED=0, PYTHONDONTWRITEBYTECODE, headless SDL, single-threaded BLAS) are set through the C runtime (_wputenv_s on Windows, setenv elsewhere). Environment.SetEnvironmentVariable does not reach CPython's Py_Initialize (measured: PYTHONHASHSEED stayed invisible, hash_randomization stayed 1). No .runsettings is needed. - Oracle/: gym_oracle.py (an embedded resource) reduces every Python value to tagged tuples of str/int/bytes/bool (describe) and returns exceptions as data (invoke). ValueFacts is the side-neutral description; .NET bytes come from NumSharp's own ndarray.data.tobytes, never through the bridge, so a match is a cross-check rather than a tautology. ByteContract reports element index, hex bits, ulp distance, key order, and a cast note that tells a missing cast from wrong values. PyGym/PyHandle/PyGymEnv build and drive live Gymnasium objects; RngFacts and GeneratorProbe describe a NumSharp Generator in NumPy's bit_generator.state form. - Pair/: GymPair compares every call in a fixed order (raised, observation, reward as float64 bits, terminated, truncated, info, an optional float64 state attribute, rng; rng only after the pair's own seeded reset, since reading Gymnasium's np_random otherwise creates an entropy-seeded generator). ParityAction carries identical bits to both sides (C# arrays cross as ToNumpyCopy, a policy's arrays come back as ToNDArray). ActionSources: open-loop, a Python policy on Gymnasium's own observation, seeded sampling with the two samples compared. GymNetEnv adapts Gym.NET's legacy API without touching a value and prints its adaptations in every report. Divergence lists every differing field of the first diverging call, the actions, and the state before and after. - Frames/: FrameDiff reports differing pixels, bounds, max channel delta and a delta histogram, and attaches python.png, dotnet.png, an amplified diff.png and stats.txt to the test result. - Reference/CartPoleReference: the positive control and, through a knob, the negative controls. - Harness self-tests (plan section 11), all passing: the faithful transcription reports nothing over 500 steps; tau = 0.02f is reported at step 1 in the state; one extra draw is reported at the reset by the generator state alone; state injection without a reset compares no unseeded generators; one pixel off by one; info key order; a float32 reward (107374182 ulps); an export Python still holds keeps the leak gate open; dtype width; the NumPy scalar = 0-d NDArray rule; the exception mapping. Conventions: - [OpenBugs("B-xx", ...)] is NumSharp's TestCategory("OpenBugs") with the port plan's defect ids; gated runs pass --filter "TestCategory!=OpenBugs". There is no runsettings default filter: a runsettings TestCaseFilter is combined with the command line's --filter, so --filter "TestCategory=OpenBugs" would silently run nothing (measured). - Byte-exact is the only default. The equivalences are the ones NumSharp's model forces: a NumPy scalar equals a 0-d NDArray of the same dtype (NumSharp has no scalar types), and a Python int equals a .NET integer of the same value. CartPole dynamics cells are pinned to the manifest's reference platform (win-x64) and report Inconclusive elsewhere. - The project follows the port plan's section 3.7 for new projects: nullable, warnings as errors (so NumSharp's ownership analyzer fails the build on an undisposed array, NDW012), deterministic, XML documentation enforced. Build plumbing: - eng/NumSharp.props: a project that declares the NumSharp.Interop.pythonnet package gets refs/NumSharp's source build of it in Submodule mode. The package would otherwise bind to the submodule's NumSharp.dll, which packs under the same NumSharp 0.70.0 identity with a different DType API, a latent MissingMethodException. Projects that don't declare it are unaffected. - Gym.Tests.Parity.csproj declares NumSharp 0.70.0 and NumSharp.Interop.pythonnet 0.70.0 next to the import, and pythonnet 3.0.5 and SixLabors.ImageSharp 2.1.13 explicitly. - Gym.NET.sln: the project under the Tests folder, added by hand. `dotnet sln add` (SDK 10) also added the three NumSharp projects and x64/x86 platforms to every project, so that edit was reverted. - tools/parity: manifest.json (the pins the session checks), requirements.txt, setup_venv.ps1 and setup_venv.sh. Both scripts verified: each created tools/parity/.venv from the pins and the suite ran on it unconfigured (103/103). The venv was removed afterwards; .gitignore ignores it. Docs: tests/Gym.Tests.Parity/CLAUDE.md (running, moving parts, recipes, rules, coverage, pitfalls); docs/plans/GYM_PARITY_INTEROP_TESTING.md Draft 2 (as-built layout and comparison table, self-tests, open bugs versus exclusions, measured costs, section 14 status and next steps); CLAUDE.md: the tree and a new section 3.3. Verification: - dotnet build tests/Gym.Tests.Parity: 0 errors in Debug and Release, with warnings as errors. - --filter "TestCategory!=OpenBugs": 103/103 in Debug and Release on net8.0 and net10.0, about 2 s per run. - --filter "TestCategory=OpenBugs": 27/27 failing; every report read and matching its defect. - tests/Gym.Tests (headless, net10.0-windows, Debug): 62/62, unaffected by the props change. - Not run: a NumSharpSource=Package restore of the parity project. It would rewrite the shared obj/ of src/Gym and src/Gym.Environments while another session builds them. Not built yet (plan sections 8-10): the reverse bridge (check_env on Gym.NET envs through a Python shim), the shared case catalog for golden record/replay, differential fuzzing, and CI wiring.
Configuration menu - View commit details
-
Copy full SHA for c08a72b - Browse repository at this point
Copy the full SHA c08a72bView commit details -
test(parity): rebuild the live parity harness on the pythonic skill
The Python half of tests/Gym.Tests.Parity is no longer an embedded module (Oracle/gym_oracle.py) driven by Python source strings. It now follows the "pythonic" skill that NumSharp and OptunaSharp use for their pythonnet bridges (refs/NumSharp/.claude/skills/pythonic/SKILL.md): - Interop/PythonRuntime.cs: the library-agnostic backend. Initialize(library) starts the one embedded CPython (throws PythonInteropException if it is already running), Run(Action)/Run<T>(Func<T>) is the only GIL entry point, Import(name) is a process-scoped module cache (loser-dispose on races, never dispose a returned handle), Builtins, Shutdown() (disposes the cache under the GIL, NoopFormatter, PythonEngine.Shutdown). - Interop/Pythonic.cs: `internal static class Python`, the tree. One facade row per FIXED Python call, spelled as Python spells it: Python.gymnasium.make(id, render_mode, kwargs) Python.gymnasium.Env.{reset, step, render, close, unwrapped, action_space, observation_space, np_random} Python.gymnasium.spaces.{Discrete, Box (scalar and array bounds)} Python.gymnasium.spaces.space.Space.{sample, contains, seed, np_random, shape, dtype} Python.gymnasium.spaces.discrete.Discrete.{n, start} Python.gymnasium.spaces.box.Box.{low, high, bounded_below, bounded_above, is_bounded} Python.gymnasium.utils.seeding.np_random Python.gymnasium.envs.classic_control.cartpole.CartPoleEnv.{state, steps_beyond_terminated, kinematics_integrator, tau} Python.gymnasium.envs.box2d.lunar_lander.heuristic Python.numpy.{dtype, int8/int32/int64/uint8/uint64/float64, atleast_1d, ndarray.{dtype, shape, tobytes_t}, random.Generator.{uniform, integers, choice, normal, exponential, random, standard_normal, bit_generator}, random.BitGenerator.state} Python.pygame.{Surface, Vector2, version, display.init, surface.Surface.fill, gfxdraw.{aapolygon, filled_polygon, aacircle, filled_circle, hline, vline}, transform.flip, surfarray.array3d, math.Vector2.{rotate_rad, x, y}} (for the CartPole rasterizer port) Python.sys / os.environ.get / platform.python_version_t / gc.collect / builtins.{int, repr} / Box2D.__version__ Object members are nested classes that take the object. Where a class is both constructed and used through its members (C# cannot give a method and a nested class one name), the constructor goes through the re-exporting package and the members through the defining module, both real Python paths (gymnasium.spaces.Discrete(...) vs gymnasium.spaces.discrete.Discrete.n(space)). Rows return owned PyObjects; `_t` variants return CLR values where only a CLR value is needed. `evalStmts` is the snippet runner (flavor B) for the two small programs that must read as Python: the session bootstrap and a strided NumPy probe. CS8981 (lower-case type names) is suppressed in this file only, as NumSharp.Core does for np/linalg/fft. - Interop/PyRef.cs: a Python object a test keeps across GIL sections, released under the GIL (a bare using would defer the decref to pythonnet's finalizer queue and keep the leak gate from settling). - Oracle/PyConvert.cs: CLR -> Python argument marshaling kept out of the tree (runtime-typed): None (a new reference, never PyObject.None), bool, ints of any width incl. BigInteger, floats, strings, NDArray as ToNumpyCopy, kwargs dicts, lists, shape/colour tuples. - Oracle/ValueFacts.FromPython replaces the tagged-tuple reader of gym_oracle.describe: runtime-dispatched reflection (isinstance chain), arrays through NumSharp's ToNDArray import + NumSharp's data.tobytes, NumPy integer scalars through int() and their own dtype.str, NumPy floating scalars through np.atleast_1d + the import (NumPy's own bytes, so NaN payloads survive; float(x) narrowing would quiet a signalling NaN), numpy.dtype objects as their dtype.str (NumSharp's DType.str on the .NET side, added to ValueFacts.Of), dicts in iteration order, tuples, lists. - Outcome.CapturePython(Func<PyObject>): runs a tree call under the GIL and returns the described value or the raised exception (QualifiedName(type), str(exception)); replaces gym_oracle.invoke. The exception's Type is its own field and is never disposed. - RngFacts.OfPython / OfPythonSpace: generator.bit_generator.state and space.np_random.bit_generator.state, described. - GymPythonSession: PythonRuntime.Initialize, the bootstrap as an evalStmts snippet, and the live stack read through the tree (pygame/Box2D import failures reported as "missing (Type: message)" drift instead of aborting). - ParityTestBase: no per-test scope, Exec or Eval any more; the session check and the leak gate remain. LeakGate pumps Python.gc.collect() inside PythonRuntime.Run. ParityAction.Python() is renamed PythonValue() so no member named Python shadows the tree. - Every test class now builds its Python side through the tree: SeedingLiveParityTests (patterns as (spelling, NumPy call, NumSharp call) triples; arrays handed to NumPy are copies of the very NumSharp arrays the .NET side draws with), Discrete/Box (candidates built with Python.numpy scalars, PyConvert values, or ToNumpyCopy of the .NET candidate), CartPole/LunarLander spaces (MadeSpace helper: make(...).unwrapped.<space>), HarnessSelfTests (the leak-gate control now holds the export in a Python list). New cell: SessionTests.ByteReaders_AgreeOnAnImportedView. The import path that every Gymnasium value takes is now verified, not assumed: for 12 dtype families (bool, int8..uint64, float16/32/64) a strided, negative-step NumPy view ([::-1, 1::2] of a 4 x 6 array) described by ValueFacts.FromPython equals NumPy's own reading of it (dtype.str, shape, tobytes() converted by pythonnet). The export cell (ByteReaders_AgreeOnAnExportedView) now also reads NumPy's own tobytes() instead of the old oracle's describe(). Removed: Oracle/gym_oracle.py (and its EmbeddedResource), Oracle/PyGym.cs (PyGym, PyHandle, PyArguments, PyExpression). Verified 2026-09-25 (Windows 11 x64, CPython 3.12.12, pythonnet 3.0.5): 131 cells = 104 gated (the previous 103 + the new imported-view cell), all passing on net10.0 and net8.0; the same 27 [OpenBugs] cells fail with ParityException at the same documented divergences (Box/Discrete P2, B-03, B-10; CartPole B-11/B-12/B-13/B-16; LunarLander F6/B-17/B-19/B-20). Debug and Release build with warnings as errors. A gated run takes ~3 s. Docs: tests/Gym.Tests.Parity/CLAUDE.md (moving parts, recipes, pythonic and GIL rules) and docs/plans/GYM_PARITY_INTEROP_TESTING.md (layout, status note) describe the new structure.Configuration menu - View commit details
-
Copy full SHA for f8a6e11 - Browse repository at this point
Copy the full SHA f8a6e11View commit details -
feat(cartpole): byte-for-byte port of Gymnasium's CartPole, with a py…
…game-exact rasterizer Gym.NET's CartPoleEnv is re-ported from refs/Gymnasium @ 9e04324 (gymnasium/envs/classic_control/cartpole.py) and now matches Gymnasium bit for bit and pixel for pixel across its whole observable surface, as proven by the live parity suite (tests/Gym.Tests.Parity) against Gymnasium itself. CartPoleEnv (src/Gym.Environments/Envs/Classic/CartPoleEnv.cs) - Gymnasium's instance attributes as settable float64 properties: Gravity, Masscart, Masspole, TotalMass, Length, PolemassLength, ForceMag, Tau, KinematicsIntegrator, ThetaThresholdRadians, XThreshold, ScreenWidth, ScreenHeight (PascalCase of the Python names, port plan §3.8). Derived ones (total_mass, polemass_length) are computed once in the constructor and not recomputed, as in Python. - State (double[]?, Gymnasium's self.state) and StepsBeyondTerminated (int?), NpRandom (the env's generator), SuttonBartoReward (ctor parameter). - Step: the source's asserts in order, now AssertionError in every build (B-14): f"{action!r} ({type(action)}) invalid" rendered with Python's repr and type ("2 (<class 'int'>) invalid", "np.int64(5) (<class 'numpy.int64'>) invalid", "None (<class 'NoneType'>) invalid", ...), then "Call reset before using step method."; Python's unpacking ValueError for a state of the wrong length; the dynamics in float64 with the source's exact operation order (np.square(v) as v * v inside its own parentheses; B-11); both integrators; termination; rewards 1.0/0.0 or, with sutton_barto_reward, 0.0/-1.0; steps_beyond_terminated; the post-termination warning through Gym.Logger. - Observations are fresh float32 copies of the state (B-13; no aliasing). - Observation space Box(-high, high, float32) with high = [4.8, inf, 0.41887903, inf] (B-12: float.MaxValue made the velocity dimensions bounded). Metadata {"render_modes": ["human", "rgb_array"], "render_fps": 50}. - Reset() and Reset(long? seed, IReadOnlyDictionary<string, object?>? options) -> (observation, {}): reseed first (super().reset(seed=seed)), then maybe_parse_reset_bounds, then uniform(low, high, size=(4,)). A reset that fails on its options has already reseeded and leaves the state untouched. - Render(mode): the exact pygame pipeline on Gym.Rendering (white fill, cart aapolygon + filled_polygon, pole corners rotated with Vector2.rotate_rad, axle aacircle + filled_circle, track hline, vertical flip, blit onto the screen surface), so rgb_array frames are pixel-identical (B-16), including pygame's errors for states it cannot draw: ValueError for NaN coordinates, OverflowError for int(inf), for a polygon vertex beyond a C long (x = 1e10) and for a circle argument beyond Sint16 (x = 300). Returns null (None) before the first reset. The human viewer is created under a private lock. - ClassicControlUtils: ports verify_number_and_cast and maybe_parse_reset_bounds (messages "An option (abc) could not be converted to a float." and "Lower bound (0.1) must be lower than higher bound (-0.1)."; OverflowError from float(int) passes through). Gym core (src/Gym) - Exceptions/AssertionError: Python's AssertionError (D17: NumSharp lacks it). - Logger: port of gymnasium/logger.py and utils/colorize.py (WARN/ERROR levels, MinLevel, Colorize with the exact ANSI sequences). - Utils/Warnings: the part of Python's warnings module the logger relies on: UserWarning shown once per (message, call site) via CallerFilePath/Line, DeprecationWarning once per message (Gymnasium's "once" filter), Output writer, Catch() = catch_warnings(record=True) + simplefilter("always"), registry reset when a catcher ends (Python's _filters_mutated). - Utils/PyBuiltins: Python built-in semantics the ported messages and conversions need: float(x) (numbers, correctly rounded big ints with "int too large to convert to float", Python's string grammar incl. whitespace/underscores/inf/nan with the right NaN sign bits, 0-d arrays only as in NumPy 2.4), int(float) with Python's NaN/inf errors, repr/str/type of the Python counterpart of .NET values (0-d NDArray = NumPy scalar np.int64(5)/np.True_, float = np.float32, n-d = np.array_repr), repr(float) (shortest digits, Python's positional/scientific thresholds), repr(str). Gym.Rendering (new project, package Gym.NET.Rendering; nullable, warnings as errors, documented) - Surface (pygame.Surface((w, h)): XRGB8888, black, fill, get_at, blit), Color, Transform.Flip, SurfArray.Array3d/ToRgbArray, Vector2.RotateRad. - GfxDraw: pygame.gfxdraw's aapolygon, filled_polygon, aacircle, filled_circle, hline, vline with pygame's argument conversion (int() then C long (32-bit, the reference platform) then unchecked Sint16 for vertices; the "h" format with "signed short integer is greater than maximum"/"less than minimum" for ints; "points must contain more than 2 points"). - SdlGfx/SdlGfxPrimitives: C# port of SDL_gfx 2.0.26 (zlib; notice kept, marked as altered) for 32-bit surfaces without alpha mask: _putPixelAlpha, pixelColor(Weight)Nolock, hline/vline, filledRectAlpha, _clipEncode, _clipLine (single-precision slope, Sint16 truncation), _aalineColor (draw_endpoint = 0, with pygame-ce's unsigned erradj shift), aapolygonColor, filledPolygonColorMT (with the MSVC runtime qsort's selection-sort behaviour for <= 8 crossings, so overflowing comparisons of Sint16-wrapped polygons order like pygame's), aaellipseColor without the 2.0.26 "od" overdraw (pygame-ce's version), filledCircleColor. - Vector2.RotateRad reproduces pygame-ce's rotation: fmod into [0, 2π), exact quarter turns within 1e-6, else cos/sin products. Found on the way: .NET 8's double % returns its own sign-set NaN for an infinite or NaN dividend, where the C runtime's fmod keeps the infinity's sign or the NaN operand, so rotate_rad(inf) differed in the NaN's sign bit on net8.0 only; the reduction now handles non-finite values as UCRT's fmod does. - Referenced by Gym.Environments; added to Gym.NET.sln by hand. Live parity suite (tests/Gym.Tests.Parity) - New Tests/Rendering/GfxDrawLiveParityTests (11 cells) against pygame-ce 2.5.8: 150 seeded random shapes per primitive on a small surface (clipping, translucent colours, fractional/negative coordinates), 40 Sint16 wrap-around polygons (the qsort comparator overflow case), compositions of 10 primitives, CartPole's shapes at 600x400, flip in 4 modes, Vector2.rotate_rad bit for bit over ~300 angles x 12 vectors (quarter turns ± just inside/outside 1e-6, huge, NaN payloads incl. signalling), and pygame's argument errors. A non-vacuity check requires most cases to change pixels on pygame's side. - CartPoleLiveParityTests rewritten: 37 cells, all gated (the B-11, B-12, B-13 and B-16 [OpenBugs] are removed): reset; 4 x 500-step balancing episodes; 5 random-action episodes past termination; termination with steps_beyond_terminated; sutton_barto_reward; semi-implicit Euler; tau = 0.05; 51 steps from an injected state; 17 reset-option cases seeded and unseeded (observation, info or exception; state and generator after); 13 invalid actions and 3 pre-reset steps (AssertionError messages); the post-termination warning (captured on both sides: one UserWarning, exact colourised text); frames at A.9's states, along 4 episodes (every 10 steps of the balancing policy, every step of random ones), at 20 extreme states and before reset; spaces; metadata. - Harness: facade rows numpy.float32 and gymnasium.Env.metadata; PyGymEnv.TryRender (frame, None or exception) and WithUnwrapped (set attributes like tau before a pair runs); Frames/RenderOutcome; GymNetEnv injects and reads ported attributes through public PascalCase properties (double[] State) and falls back to legacy NDArray fields; ExceptionMap maps OverflowError to System.OverflowException, the type NumSharp throws for NumPy's (Generator.uniform "high - low range exceeds valid bounds"). - Mutation checks (not committed) confirmed the new cells bite: a changed ValueError message, AssertionError format, warning text or pole width each fail their cells. Unit tests (tests/Gym.Tests, offline): CartPoleTests updated (AssertionError messages instead of ArgumentException, step before reset, float32 copies, warn-once past termination); GymnasiumRngParityTests reads the float64 state from CartPoleEnv.State and the float32 observation; new PyBuiltinsTests, LoggerTests, ClassicControlUtilsTests (incl. the docstring example reset(seed=123, options={low: -0.1, high: 0.1}) by float32 bits) and Rendering/RasterizerTests. Docs: THIRD-PARTY-NOTICES.md (Gymnasium MIT, SDL_gfx zlib, pygame-ce as a behaviour reference only); port plan defect register marks B-11..B-16 fixed with their gating cells; parity plan status; the parity project guide (coverage table, ExceptionMap, the .NET 8 % pitfall); root CLAUDE.md layout; eng/NumSharp.props project list. Verified 2026-09-25 (Windows 11 x64, .NET SDK 10, CPython 3.12.12, NumPy 2.4.2, pygame-ce 2.5.8, Gymnasium 9e04324): - tests/Gym.Tests.Parity: 160 cells, 146 gated, all passing on net8.0 and net10.0; the 14 open cells are spaces (P2, B-03, B-10) and LunarLander (F6, B-17, B-19, B-20), none CartPole. - tests/Gym.Tests (headless): 125 of 125 on net8.0-windows and net10.0-windows. - Gym.NET.sln builds in Debug and Release.Configuration menu - View commit details
-
Copy full SHA for 0fa7bb2 - Browse repository at this point
Copy the full SHA 0fa7bb2View commit details -
feat(core): port Gymnasium's core API (P1): Env<TObs, TAct>, wrappers…
…, specs, errors, PyMath Completes phase P1 of docs/plans/GYMNASIUM_PORT_PLAN.md (section 17). Exit criterion met: tests/test_core.py is ported (tests/Gym.Tests/Ported/TestCore.cs) and green on net8.0 and net10.0, and the live parity suite now compares the core API itself with Gymnasium (refs/Gymnasium @ 9e04324) call by call. Core contract (src/Gym/Envs, port of gymnasium/core.py) - IEnv: Gymnasium's Env over object (Metadata, RenderMode, Spec, ActionSpace/ObservationSpace, NpRandom, NpRandomSeed, Unwrapped, Step(object) -> StepResult<object?>, Reset(BigInteger? seed, options) -> ResetResult<object?>, Render() -> RenderResult, Close, Has/Get/SetWrapperAttr), IDisposable (= __exit__). - Env<TObs, TAct> replaces the legacy Env/Env<TAction>: abstract Step(TAct)/Reset; SeedNpRandom(seed) is the port of super().reset(seed=seed); NpRandom is created lazily from OS entropy, NpRandomSeed is -1 once a generator is assigned and read-only (AttributeError "property 'np_random_seed' of 'X' object has no setter"); _np_random/_np_random_seed reachable by Python name; ActionSpace/ObservationSpace raise Python's AttributeError while unset (GenericTestEnv's error cases); default metadata {"render_modes": []} (per instance, documented deviation from Python's shared class attribute); Render raises NotImplementedError; ToString is Env.__str__ (<CartPoleEnv instance>, <CartPoleEnv<CartPole-v1>>); ConvertAction maps untyped actions (any integer width, enum, 0-d arrays) for IEnv.Step; implements IDynamicAttributes so setattr of new names works as on a Python instance. - Wrapper<TWObs, TWAct, TObs, TAct> (+ Wrapper<TObs, TAct>, IWrapper): forwards everything; own action/observation space and metadata override; RenderMode/Spec read-only (AttributeError messages); NpRandom/NpRandomSeed forward to the innermost env; _np_random raises Gymnasium's "Can't access `_np_random` of a wrapper, ..."; Spec is a cached deep copy of the env's spec extended with WrapperSpec(ClassName(), "Namespace:Class", recorded kwargs or None); has/get/set_wrapper_attr walk the stack exactly as core.py does (force=False down the stack, create on the outermost layer); ToString <TimeLimit<OrderEnforcing<...>>>; mismatched TWObs/TObs without an override throw InvalidCastException naming the fix instead of passing garbage on. - ObservationWrapper / RewardWrapper / ActionWrapper with abstract Observation/Reward/Action hooks. - Info (a Python dict: insertion order, re-assignment in place, KeyError('x'), Get/Pop/Update/Copy/DeepCopy, order-insensitive Python ==, repr), Metadata (RenderModes/RenderFps accessors), RenderResult (closed union: None, Rgb, Depth, RgbDepth, Ansi, Frames; structural equality that never broadcasts), EnvExtensions.StepAsync (Task.Run; replaces the Thread.Abort-based DistributedScheduler, B-34). - Gym.Observations: StepResult<TObs> (float64 reward), ResetResult<TObs>, DoneStepResult<TObs>, record structs with exact equality (arrays by shape, dtype and elements; never NDArray's broadcasting Equals) and consistent hashes. Registration (src/Gym/Envs/Registration/Registration.cs, the spec half of gymnasium/envs/registration.py) - EnvSpec (sealed record, dataclass semantics: settable fields, namespace/name/version parsed once at construction, kwargs compared as dicts, dataclass repr, pprint, DeepCopy), WrapperSpec (+ WrapperSpec.For), EnvCreator, EnvRegistry.ParseEnvId/GetEnvId (ENV_ID_RE with fullmatch semantics, Gymnasium's "Malformed environment ID" Error, Unicode digit versions). make/register/registry stay in P3. Wrappers pulled forward from P3 because test_core.py needs them (src/Gym/Wrappers/Common.cs, wrappers/common.py) - TimeLimit<TObs, TAct>: argument checks before the env (TypeError for None, ValueError for <= 0, Gymnasium's messages), _elapsed_steps None until reset (stepping first raises Python's "unsupported operand type(s) for +=: 'NoneType' and 'int'" after the env stepped, as Python does), truncation at elapsed >= limit, spec with max_episode_steps and no WrapperSpec, recorded kwargs. - OrderEnforcing<TObs, TAct>: ResetNeeded messages for step and render, has_reset, _disable_render_order_enforcing, spec with order_enforce=True. Errors, utilities - Every class of gymnasium/error.py in Gym.Exceptions with the source hierarchy (UnregisteredEnv family, DeprecatedWrapper : ImportError, AlreadyPendingCallError/NoAsyncCallError with Name, ClosedEnvironmentError, CustomSpaceError not Error subclasses); NotImplementedError and ImportError join AssertionError; parameterless constructors give Python's empty message. - PyAttributes (hasattr/getattr/setattr over Python names: [PyName], snake_case of public members, run-time attributes via IDynamicAttributes, Python's AttributeError/TypeError messages, read-only checked before the value). - PyMath (F3): FloorDiv/Mod/DivMod for long and double (CPython's float_div_mod on the UCRT fmod, NaN bits included; ZeroDivisionError messages as DivideByZeroException), argument-order Min/Max, Round(x) and exact decimal Round(x, ndigits) (dtoa mode 3 via BigInteger, NDIGITS limits, overflow error), NumPy Clip (tie-preserving: found that clip(-0.0, 0.0, 1) is -0.0, which min(max()) gets wrong) and Sign, Float32. - PyEquality (Python ==: cross-type numbers exactly, dicts order-insensitive, lists vs tuples, arrays via np.array_equal, identity first), PyCopy (deepcopy of specs, dicts, lists, arrays), PyBuiltins.Repr/TypeRepr of dicts/lists/tuples, RecordConstructorArgs (IRecordConstructorArgs + Record: most-derived constructor wins, the C# order of Python's first-call-wins), StepApiCompatibility (single-env conversions, info edited in place). - Space.Seed takes a BigInteger (an env's 128-bit np_random_seed seeds a space, as testing_env.py does). Environments - CartPoleEnv : Env<NDArray, long>: ctor (suttonBartoReward, renderMode, viewerFactory) plus an IEnvViewer overload; Reset(seed, options)/Step(long) return the 5-tuple (float64 reward, truncated False, {}); human mode renders in reset/step, paced by the new Gym.Rendering.Clock (port of pygame.time.Clock.tick) at metadata["render_fps"]; rgb_array returns RenderResult.Rgb; other modes draw and return None; render without a mode asserts a spec and warns like Gymnasium; close keeps the screen (as Python) and closes the window; isopen, screen, clock, surf and _sutton_barto_reward reachable by Python name; the untyped step asserts on the value as given. - LunarLanderEnv : Env<NDArray, object> (minimal migration; P8 re-ports it): base NpRandom (random_state assigns it), render_mode, metadata, 5-tuple, human auto-render, RenderResult; the in-place Seed trick is gone (a seeded reset gives the env a new generator, as Gymnasium). Its 8 determinism baselines still hold. Legacy (src/Gym/Legacy, [Obsolete], port plan section 16) - Step (moved from Gym.Observations), ILegacyEnv (the former IEnv), LegacyEnvAdapter (pre-0.26 API over any IEnv, built on StepApiCompatibility: Seed(s) + Reset() = Reset(seed: s), done + "TimeLimit.truncated", float32 reward, construction-time render mode only), and the baselines-style VecEnv family over ILegacyEnv until P6. - Deleted: GoalEnv (B-32) and src/Gym/Internal/Threading (B-34); WinFormEnvViewer inlines the one helper it used. Tests - Ported (tests/Gym.Tests/Ported): testing_env.py (GenericTestEnv), test_core.py (all 9 tests; test_get_set_wrapper_attr on make's stack built by hand), tests/utils/test_seeding.py, test_step_api_compatibility.py (single env), tests/wrappers/test_order_enforcing.py, test_time_limit.py (GenericTestEnv for PendulumEnv, P4). - New unit tests: Info/PyEquality/repr, PyAttributes, PyMath (values from CPython 3.12.12 / NumPy 2.4.2), EnvSpec, wrappers (spec propagation, read-only properties, descriptions), RenderResult, LegacyEnvAdapter; CartPole tests for render modes, laziness and attributes; existing tests moved to the new API. - Unit suite: 211/211 on net8.0-windows and net10.0-windows. - Live parity suite: GymNetEnv now drives any IEnv with no adaptations (5-tuple, float64 reward, attributes by Python name through PyAttributes on the unwrapped env). New cells (Tests/Core/CoreLiveParityTests.cs, Tests/Utils/PyMathLiveParityTests.cs): make("CartPole-v1")'s stack vs TimeLimit(OrderEnforcing(CartPoleEnv)) through truncation at step 500; OrderEnforcing and TimeLimit call by call; the attribute protocol through a stack; np_random/np_random_seed; render without a render mode; EnvSpec repr/pprint; parse_env_id/get_env_id; step-API conversions; dict repr; PyMath over edge corpora (512 np.clip triples, 17 round digit counts, NaN payloads). New facade rows in Interop/Pythonic.cs (builtins str/getattr/setattr/divmod/round/min/max, operator, np.clip/sign, Env spec/np_random_seed/wrapper-attr rows, wrappers.TimeLimit/OrderEnforcing, registration, step_api_compatibility, classic_control.CartPoleEnv); PyConvert converts dicts/tuples/lists; ExceptionMap maps ZeroDivisionError and ImportError. 177 cells: 163 gated, all passing on net8.0 and net10.0; the same 14 [OpenBugs] (spaces P2, LunarLander P8). Five planted defects were each caught by their cell (TimeLimit off by one, textbook clip, unquoted attribute name, half-up decimal rounding, a spec repr's one-element tuple comma). - Documented deviation kept out of the gated corpus: Python's `dones and truncated` returns an operand, so a non-boolean "TimeLimit.truncated" stays an int in Python; StepResult's flags are bool. Docs: port plan section 17 progress note (P1 done; spaces non-generic until P2; Metadata.AutoresetMode with P6), Appendix B (B-15, B-31, B-32, B-33, B-34), Appendix C.1 and C.9; parity plan status (Draft 3) and harness guide; root CLAUDE.md (new layout and contract); README example on the new API (verified by running it headlessly).Configuration menu - View commit details
-
Copy full SHA for da428ab - Browse repository at this point
Copy the full SHA da428abView commit details -
refactor(core): float64 legacy rewards, ValueTask StepAsync, docs in …
…Gym.NET's own voice Three maintainer directives on top of the P1 core API (da428ab), 2026-09-25: 1. "Then move reward to double." (on the legacy Step.Reward, documented as "narrowed to float32 (Gymnasium's rewards are float64)") 2. "All Task.Run should be replaced with ValueTask StepAsync" 3. "Our documentations should not state 'port of thisfile.py', we are our own codebase, have remarks reference to the github file of gymnasium if we are indeed based on it but that's as far as it goes for 'WE ARE GYMNASIUM' - no, we are Gym." 1. Rewards are float64 everywhere, the legacy API included ---------------------------------------------------------- - Gym.Legacy.Step.Reward is double (was float); its constructor and Deconstruct take and return double. No reward in Gym.NET is narrowed to float32 any more. - ILegacyEnv.RewardRange, IVecEnv.RewardRange, VecEnv and LegacyEnvAdapter.RewardRange are (double From, double To); the adapter's default is (-inf, +inf). - LegacyEnvAdapter passes the env's float64 reward through unchanged: ToLegacyStep, shared by Step and StepAsync, goes through StepApiCompatibility.ConvertToDoneStepApi and copies the info into the legacy Dict. - LegacyEnvAdapterTests asserts the reward stays float64. 2. StepAsync is a ValueTask member; Task.Run is gone from stepping ------------------------------------------------------------------ - Deleted src/Gym/Envs/EnvExtensions.cs (the StepAsync extensions over Task.Run). - Env<TObs, TAct>.StepAsync(TAct, CancellationToken) is virtual and returns ValueTask<StepResult<TObs>>. The default runs Step on the calling thread and completes synchronously (no thread-pool hop, no allocation). A token already canceled gives ValueTask.FromCanceled without stepping; an exception from Step gives ValueTask.FromException, so it surfaces at the caller's await, as for any asynchronous method. Envs with genuinely asynchronous dynamics (a remote simulator, a batched GPU step) override it. - IEnv.StepAsync(object?, CancellationToken) returns ValueTask<StepResult<object?>>. Env implements it explicitly as an async method that converts the action (ConvertAction) and awaits the typed StepAsync, so an unconvertible action faults the task (TypeError) instead of throwing from the call. - Wrappers deliberately do not override StepAsync: the inherited default calls the wrapper's own Step, so every wrapper's Step override (TimeLimit, OrderEnforcing, the hook wrappers) applies. Forwarding to the inner env's StepAsync would bypass them. A wrapper that must keep an asynchronous env asynchronous overrides StepAsync itself; Env.StepAsync's remarks say so. - Legacy: ILegacyEnv.StepAsync returns ValueTask<Step>; LegacyEnvAdapter.StepAsync awaits the env's IEnv.StepAsync; VecEnv.StepAsync returns ValueTask<Step[]> computed on the calling thread (a failure faults the task); VecEnvWrapper overrides it to await each sub-env's own StepAsync, one after another. The DistributedScheduler (B-34) stays deleted. - Tests: EnvTests.StepAsync_CompletesWithTheStep (the task is IsCompletedSuccessfully before any await, typed and untyped steps reach the env, an unconvertible action faults the task with TypeError, a canceled token cancels it before the env is touched) and LegacyEnvAdapterTests.StepAsync_MatchesStep (20 seeded CartPole steps in lockstep with Step: same observation, reward, done and info, each task complete on return; an invalid action faults with AssertionError). - Task.Run remains only in the concurrency tests (ConcurrencyTests and LunarLander's two-instance Avalonia test), where it creates the parallel threads under test and has nothing to do with stepping: a StepAsync there would complete on the calling thread and make those tests sequential. 3. Documentation in Gym.NET's own voice, with pinned GitHub references ---------------------------------------------------------------------- Every "Port of <file>.py", "ported from", "the port", "a ported env/test", "ported verbatim" and "file.py:NN @ 9e04324" citation in src/ and tests/ is gone (grep-verified). Summaries describe Gym.NET's own member: what it does, what it costs, its footguns. Where a member is based on Gymnasium, its <remarks> carries the source as a permalink pinned to the refs/Gymnasium commit: Reference: <see href="https://github.com/Farama-Foundation/Gymnasium/blob/ 9e04324f6b0adbe19112206dfe247edc4142e7ec/gymnasium/core.py#L25">gymnasium/core.py</see>. Other sources use the same form at their pinned tags: pygame-ce (blob/2.5.8: src_c/time.c, gfxdraw.c, surface.c, transform.c, SDL_gfx/SDL_gfxPrimitives.c, src_py/surfarray.py), CPython (blob/v3.12.12: Objects/floatobject.c, Python/bltinmodule.c) and NumPy (blob/v2.4.2: numpy/_core/src/umath/clip.cpp). Every line anchor was checked against the pinned sources. Two old citations were stale and are corrected: GenericTestEnv was cited at testing_env.py:55 (it is L60), and CartPoleReference's cartpole.py spans predated the pin (reset is L228, step L164, the __init__ attributes L124-136). Scope: - src/Gym: Env, IEnv, the Wrapper family, Registration (EnvCreator, WrapperSpec, EnvSpec, EnvRegistry), every error class (error.py references; the message parameter now reads "Gym's own raise sites pass Gymnasium's text verbatim"), StepResult/ResetResult/ DoneStepResult, Info, Metadata, Logger, Warnings, TimeLimit, OrderEnforcing, Seeding, StepApiCompatibility, RecordConstructorArgs, PyMath, PyAttributes, PyBuiltins, Space/Box/Discrete (Space, Box and Discrete had no class summary; they have one now), and the Legacy docs ("before the core-API work (P1)", "any current env"). - src/Gym.Environments: CartPoleEnv (class, Reset, Step, Render, Close), ClassicControlUtils, LunarLanderEnv. LunarLanderEnv's class doc was malformed XML before this change (a `/// <summary>` opened above a `//` block and closed below it: CS1587 and two CS1570, so the class had no documentation). The Gymnasium docstring stays as plain comments; the class now has a valid summary, and remarks that name its open gaps (F6, B-17, B-18) and the reference. - src/Gym.Rendering: Clock (and Tick, GetTime, GetRawTime, GetFps), GfxDraw, SdlError, SdlGfxPrimitives, SurfArray, Surface, Transform. The SDL_gfx file header keeps "This file is a C# port of parts of SDL_gfxPrimitives.c": zlib requires altered versions to be plainly marked. - tests: the Gymnasium-derived suites under tests/Gym.Tests/Ported (TestCore, TestingEnv, TestSeeding, TestStepApiCompatibility, TestOrderEnforcing, TestTimeLimit) read "Gymnasium's <x> tests, run against Gym.NET", with a Reference on each class, test and helper; the unit tests (SeedTests, EnvSpecTests, ClassicControlUtilsTests, CartPoleTests, BoxTest, GymnasiumRngParityTests, LunarLanderEnvironment, LegacyEnvAdapterTests); the live parity harness (OpenBugsAttribute, ExceptionMap, GymNetEnv, CartPoleReference, GymPythonSession and ParityTestBase "adapted from NumSharp's", and the Core, CartPole, LunarLander, Harness, GfxDraw, Seeding, Box and Discrete cell classes). - The Gym.Tests.Ported namespace and folder are unchanged (code structure, not documentation). Docs: - CLAUDE.md: the overview says Gym.NET is its own toolkit, API-compatible and byte-aligned with Gymnasium; the tree drops "port of ..." and EnvExtensions; the contract lists StepAsync; the legacy bullet notes the float64 reward; a new "Documentation voice" bullet states the rule. - GYMNASIUM_PORT_PLAN.md: §3.1 rule 2 is now "Reference the source, in Gym.NET's own voice", with the permalink form; the §6 sketches follow it; §15 describes the ValueTask StepAsync (Task.Run would hide a thread hop and a thread-pool dependency behind every step; parallel stepping is AsyncVectorEnv's job, P6); the P1 progress note records this follow-up; the B-34 row names StepAsync_CompletesWithTheStep. - GYMNASIUM_PARITY_AUDIT.md and GYM_PARITY_INTEROP_TESTING.md: the "its port" and "re-ported" wording. Verification (2026-09-25) ------------------------- - dotnet build Gym.NET.sln -c Debug and -c Release: 0 errors. Release's 4 warnings are the pre-existing NuGet packaging ones (NU5048 PackageIconUrl, NU5125 licenseUrl). - XML-doc check (-p:GenerateDocumentationFile=true on Gym, Gym.Rendering, Gym.Environments, Gym.Tests, Gym.Tests.Parity): no CS1570-CS1589, CS1658 or CS1710-CS1735 warning in any file this commit edits; LunarLanderEnv's three are fixed. The remaining ones are pre-existing, in files this commit does not touch (Internal/*, IEnvViewer.cs, the LunarLanderEnvironment test's class comment). - tests/Gym.Tests: 212/212 passed on net8.0-windows and net10.0-windows. - tests/Gym.Tests.Parity, gate (TestCategory!=OpenBugs): 163/163 passed on net8.0 and net10.0. - tests/Gym.Tests.Parity, TestCategory=OpenBugs: the same 14 open cells fail on both frameworks, as before.
Configuration menu - View commit details
-
Copy full SHA for ed68964 - Browse repository at this point
Copy the full SHA ed68964View commit details
This comparison is taking too long to generate.
Unfortunately it looks like we can’t render this comparison for you right now. It might be too big, or there might be something weird with your repository.
You can try running this command locally to see the comparison on your machine:
git diff master...feat/numsharp-master-migration
