Comparing master...feat/numsharp-master-migration · SciSharp/Gym.NET · GitHub
Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: SciSharp/Gym.NET
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: master
Choose a base ref
...
head repository: SciSharp/Gym.NET
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: feat/numsharp-master-migration
Choose a head ref
Checking mergeability… Don’t worry, you can still create the pull request.
  • 13 commits
  • 147 files changed
  • 1 contributor

Commits on Sep 25, 2026

  1. 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.
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    801f123 View commit details
    Browse the repository at this point in the history
  2. 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).
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    c110cbc View commit details
    Browse the repository at this point in the history
  3. 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.
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    c141717 View commit details
    Browse the repository at this point in the history
  4. 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".
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    684ca1d View commit details
    Browse the repository at this point in the history
  5. 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.
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    2a1b827 View commit details
    Browse the repository at this point in the history
  6. 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.
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    ede57bd View commit details
    Browse the repository at this point in the history
  7. 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).
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    b51689d View commit details
    Browse the repository at this point in the history
  8. 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).
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    cf0dd77 View commit details
    Browse the repository at this point in the history
  9. 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.
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    c08a72b View commit details
    Browse the repository at this point in the history
  10. 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.
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    f8a6e11 View commit details
    Browse the repository at this point in the history
  11. 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.
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    0fa7bb2 View commit details
    Browse the repository at this point in the history
  12. 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).
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    da428ab View commit details
    Browse the repository at this point in the history
  13. 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.
    Nucs committed Sep 25, 2026
    Configuration menu
    Copy the full SHA
    ed68964 View commit details
    Browse the repository at this point in the history
Loading