frondori-engine 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Melvine Nargeot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: frondori-engine
3
+ Version: 0.4.0
4
+ Summary: Frondori research environments: the common contract (PettingZoo API), registry, rendering format and contract tests. Environments are installed separately (frondori-football, frondori-kitchen...).
5
+ Author: Melvine Nargeot
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://frondori.com
8
+ Project-URL: Documentation, https://frondori.com/documentation/train-locally
9
+ Project-URL: Source, https://github.com/Melvin-klein/frondori-engine
10
+ Project-URL: Issues, https://github.com/Melvin-klein/frondori-engine/issues
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Operating System :: OS Independent
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: numpy>=1.24
25
+ Requires-Dist: gymnasium>=1.0
26
+ Requires-Dist: pettingzoo>=1.24
27
+ Requires-Dist: msgpack>=1.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # frondori-engine
33
+
34
+ Le contrat commun des environnements de recherche de
35
+ Frondori, au format [PettingZoo](https://pettingzoo.farama.org/).
36
+
37
+ **Ce paquet ne contient aucun environnement.** Chacun est un paquet à part,
38
+ dans son propre dépôt, qu'on installe seulement s'il nous intéresse :
39
+
40
+ | Paquet | Environnement |
41
+ |---|---|
42
+ | `frondori-football` | `football-v0` — football 2D, duel (moteur Rust) |
43
+ | `frondori-kitchen` | `kitchen-v0` — cuisine coopérative |
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install frondori-engine frondori-kitchen
49
+ ```
50
+
51
+ (Pas encore publiés sur PyPI : depuis un clone, `pip install -e .` dans
52
+ chaque dépôt.)
53
+
54
+ ## Utilisation
55
+
56
+ ```python
57
+ import frondori_engine
58
+
59
+ print(frondori_engine.registered_ids()) # les environnements installés
60
+ env = frondori_engine.make("kitchen-v0") # un pettingzoo.ParallelEnv
61
+ observations, infos = env.reset(seed=42)
62
+
63
+ while env.agents:
64
+ actions = {agent: env.action_space(agent).sample() for agent in env.agents}
65
+ observations, rewards, terminations, truncations, infos = env.step(actions)
66
+ ```
67
+
68
+ Sans serveur, sans réseau, sans token. Compatible avec l'écosystème qui parle
69
+ PettingZoo (RLlib, TorchRL, CleanRL, SuperSuit...). Pour jouer un match en
70
+ local dans les conditions de la compétition, ou contre d'autres agents sur
71
+ le serveur, voir le SDK (`frondori-sdk`) : `Agent(..., local=True)`.
72
+
73
+ ## Ce que contient ce paquet
74
+
75
+ - `frondori_engine.make` / `registered_ids` / `register` : le registre. Les
76
+ environnements installés sont découverts automatiquement par leur *entry
77
+ point* (groupe `frondori.environments`).
78
+ - `frondori_engine.scene` : le format de rendu commun (`render_mode="scene"`).
79
+ - `frondori_engine.wire` : le format des spaces et des valeurs sur le réseau.
80
+ - `frondori_engine.worker` : exécute un environnement pour le serveur de
81
+ compétition (un processus par match).
82
+ - `frondori_engine.testing` : les tests du contrat, à appeler depuis les
83
+ tests de chaque environnement (`check_environment("mon-jeu-v0")`).
84
+
85
+ ## Créer un environnement
86
+
87
+ Un paquet Python qui dépend de `frondori-engine` et se déclare dans son
88
+ `pyproject.toml` :
89
+
90
+ ```toml
91
+ [project.entry-points."frondori.environments"]
92
+ "mon-jeu-v0" = "mon_paquet:MonJeuEnv"
93
+ ```
94
+
95
+ Guide complet, avec un exemple vérifié : Documentation > Create an
96
+ Environment, sur le site. L'exemple de ce guide (`tests/rps.py`) sert aussi
97
+ d'environnement de test à ce paquet.
98
+
99
+ ## Développer / tester
100
+
101
+ ```bash
102
+ python -m venv .venv && source .venv/bin/activate
103
+ pip install -e ".[dev]"
104
+ python -m pytest
105
+ ```
106
+
107
+ ## Publier une version
108
+
109
+ 1. Mettre à jour `version` dans `pyproject.toml` et commiter.
110
+ 2. Pousser un tag du même numéro : `git tag v0.1.0 && git push origin v0.1.0`.
111
+
112
+ La CI (`.github/workflows/ci.yml`) teste, construit et publie sur PyPI ; elle
113
+ refuse un tag qui ne correspond pas à la version. Publication par *Trusted
114
+ Publishing*, sans token : à configurer une fois sur PyPI (projet `frondori-engine` >
115
+ Publishing > trusted publisher GitHub : ce dépôt, workflow `ci.yml`,
116
+ environnement `pypi`).
117
+
118
+ À publier EN PREMIER : les environnements et le SDK en dépendent.
119
+
120
+ Licence : MIT.
@@ -0,0 +1,89 @@
1
+ # frondori-engine
2
+
3
+ Le contrat commun des environnements de recherche de
4
+ Frondori, au format [PettingZoo](https://pettingzoo.farama.org/).
5
+
6
+ **Ce paquet ne contient aucun environnement.** Chacun est un paquet à part,
7
+ dans son propre dépôt, qu'on installe seulement s'il nous intéresse :
8
+
9
+ | Paquet | Environnement |
10
+ |---|---|
11
+ | `frondori-football` | `football-v0` — football 2D, duel (moteur Rust) |
12
+ | `frondori-kitchen` | `kitchen-v0` — cuisine coopérative |
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install frondori-engine frondori-kitchen
18
+ ```
19
+
20
+ (Pas encore publiés sur PyPI : depuis un clone, `pip install -e .` dans
21
+ chaque dépôt.)
22
+
23
+ ## Utilisation
24
+
25
+ ```python
26
+ import frondori_engine
27
+
28
+ print(frondori_engine.registered_ids()) # les environnements installés
29
+ env = frondori_engine.make("kitchen-v0") # un pettingzoo.ParallelEnv
30
+ observations, infos = env.reset(seed=42)
31
+
32
+ while env.agents:
33
+ actions = {agent: env.action_space(agent).sample() for agent in env.agents}
34
+ observations, rewards, terminations, truncations, infos = env.step(actions)
35
+ ```
36
+
37
+ Sans serveur, sans réseau, sans token. Compatible avec l'écosystème qui parle
38
+ PettingZoo (RLlib, TorchRL, CleanRL, SuperSuit...). Pour jouer un match en
39
+ local dans les conditions de la compétition, ou contre d'autres agents sur
40
+ le serveur, voir le SDK (`frondori-sdk`) : `Agent(..., local=True)`.
41
+
42
+ ## Ce que contient ce paquet
43
+
44
+ - `frondori_engine.make` / `registered_ids` / `register` : le registre. Les
45
+ environnements installés sont découverts automatiquement par leur *entry
46
+ point* (groupe `frondori.environments`).
47
+ - `frondori_engine.scene` : le format de rendu commun (`render_mode="scene"`).
48
+ - `frondori_engine.wire` : le format des spaces et des valeurs sur le réseau.
49
+ - `frondori_engine.worker` : exécute un environnement pour le serveur de
50
+ compétition (un processus par match).
51
+ - `frondori_engine.testing` : les tests du contrat, à appeler depuis les
52
+ tests de chaque environnement (`check_environment("mon-jeu-v0")`).
53
+
54
+ ## Créer un environnement
55
+
56
+ Un paquet Python qui dépend de `frondori-engine` et se déclare dans son
57
+ `pyproject.toml` :
58
+
59
+ ```toml
60
+ [project.entry-points."frondori.environments"]
61
+ "mon-jeu-v0" = "mon_paquet:MonJeuEnv"
62
+ ```
63
+
64
+ Guide complet, avec un exemple vérifié : Documentation > Create an
65
+ Environment, sur le site. L'exemple de ce guide (`tests/rps.py`) sert aussi
66
+ d'environnement de test à ce paquet.
67
+
68
+ ## Développer / tester
69
+
70
+ ```bash
71
+ python -m venv .venv && source .venv/bin/activate
72
+ pip install -e ".[dev]"
73
+ python -m pytest
74
+ ```
75
+
76
+ ## Publier une version
77
+
78
+ 1. Mettre à jour `version` dans `pyproject.toml` et commiter.
79
+ 2. Pousser un tag du même numéro : `git tag v0.1.0 && git push origin v0.1.0`.
80
+
81
+ La CI (`.github/workflows/ci.yml`) teste, construit et publie sur PyPI ; elle
82
+ refuse un tag qui ne correspond pas à la version. Publication par *Trusted
83
+ Publishing*, sans token : à configurer une fois sur PyPI (projet `frondori-engine` >
84
+ Publishing > trusted publisher GitHub : ce dépôt, workflow `ci.yml`,
85
+ environnement `pypi`).
86
+
87
+ À publier EN PREMIER : les environnements et le SDK en dépendent.
88
+
89
+ Licence : MIT.
@@ -0,0 +1,25 @@
1
+ """Le contrat commun des environnements de recherche Frondori.
2
+
3
+ Ce paquet ne contient AUCUN environnement : chacun est un paquet à part,
4
+ installé selon ses besoins, et découvert automatiquement :
5
+
6
+ pip install frondori-engine frondori-kitchen
7
+
8
+ import frondori_engine
9
+
10
+ print(frondori_engine.registered_ids()) # ['kitchen-v0']
11
+ env = frondori_engine.make("kitchen-v0")
12
+ observations, infos = env.reset(seed=42)
13
+ while env.agents:
14
+ actions = {agent: env.action_space(agent).sample() for agent in env.agents}
15
+ observations, rewards, terminations, truncations, infos = env.step(actions)
16
+
17
+ Il fournit ce que tous partagent : le registre (`make`, `register`), le
18
+ format des scènes (`scene`), celui des spaces et des valeurs sur le réseau
19
+ (`wire`), le worker qui exécute un environnement pour le serveur de
20
+ compétition (`worker`) et les tests du contrat (`testing`).
21
+ """
22
+
23
+ from frondori_engine.registry import ENTRY_POINT_GROUP, EnvSpec, get_spec, make, register, registered_ids
24
+
25
+ __all__ = ["ENTRY_POINT_GROUP", "EnvSpec", "get_spec", "make", "register", "registered_ids"]
@@ -0,0 +1,104 @@
1
+ """Registre des environnements, sur le modèle de `gymnasium.make`.
2
+
3
+ Les environnements ne vivent PAS dans ce paquet : chacun est un paquet
4
+ Python indépendant (`frondori-football`, `frondori-kitchen`...), dans son
5
+ propre dépôt, qu'un chercheur installe seulement s'il l'intéresse. Un
6
+ paquet se déclare par un *entry point* du groupe `frondori.environments`,
7
+ dans son `pyproject.toml` :
8
+
9
+ [project.entry-points."frondori.environments"]
10
+ "kitchen-v0" = "frondori_kitchen:KitchenEnv"
11
+
12
+ Au premier appel à `make`/`registered_ids`, le registre découvre tous les
13
+ environnements installés (`importlib.metadata.entry_points`, le mécanisme
14
+ standard des plugins Python, celui de pytest ou de Gymnasium). Chaque classe
15
+ n'est importée qu'au moment de créer l'environnement : lister le catalogue
16
+ ne charge aucun moteur.
17
+
18
+ `register` reste disponible pour un environnement en cours d'écriture, pas
19
+ encore empaqueté.
20
+
21
+ Tout identifiant porte une version (`nom-vN`). Changer les règles d'un
22
+ environnement, c'est en publier une nouvelle version, jamais modifier
23
+ l'ancienne : un résultat, un replay ou un classement obtenu sur
24
+ `football-v0` doit rester comparable dans six mois.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ from dataclasses import dataclass, field
31
+ from importlib.metadata import EntryPoint, entry_points
32
+ from typing import Any, Callable
33
+
34
+ from pettingzoo import ParallelEnv
35
+
36
+ ENTRY_POINT_GROUP = "frondori.environments"
37
+
38
+ _ID_PATTERN = re.compile(r"^[a-z][a-z0-9_]*-v\d+$")
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class EnvSpec:
43
+ id: str
44
+ entry_point: Callable[..., ParallelEnv]
45
+ kwargs: dict[str, Any] = field(default_factory=dict)
46
+ # Paquet installable qui fournit l'environnement (ex. "frondori-kitchen"),
47
+ # `None` pour un environnement enregistré à la main. Publié avec le
48
+ # catalogue : le site en tire la commande d'installation.
49
+ package: str | None = None
50
+
51
+
52
+ _registry: dict[str, EnvSpec] = {}
53
+ _discovered = False
54
+
55
+
56
+ def register(id: str, entry_point: Callable[..., ParallelEnv], package: str | None = None, **kwargs: Any) -> None:
57
+ """Enregistre un environnement. `kwargs` : paramètres par défaut passés à
58
+ `entry_point`, surchargeables au moment de `make`."""
59
+ if not _ID_PATTERN.match(id):
60
+ raise ValueError(f"identifiant invalide {id!r} : format attendu 'nom-vN' (ex. 'football-v0')")
61
+ if id in _registry:
62
+ raise ValueError(f"environnement {id!r} déjà enregistré ({_registry[id].package or 'register()'})")
63
+ _registry[id] = EnvSpec(id=id, entry_point=entry_point, kwargs=kwargs, package=package)
64
+
65
+
66
+ def make(id: str, **kwargs: Any) -> ParallelEnv:
67
+ try:
68
+ spec = get_spec(id)
69
+ except KeyError:
70
+ known = ", ".join(registered_ids()) or "(aucun : installer un paquet d'environnement, ex. frondori-kitchen)"
71
+ raise KeyError(f"environnement inconnu {id!r} ; disponibles : {known}") from None
72
+ return spec.entry_point(**{**spec.kwargs, **kwargs})
73
+
74
+
75
+ def get_spec(id: str) -> EnvSpec:
76
+ _discover()
77
+ return _registry[id]
78
+
79
+
80
+ def registered_ids() -> list[str]:
81
+ _discover()
82
+ return sorted(_registry)
83
+
84
+
85
+ def _discover() -> None:
86
+ """Enregistre les environnements des paquets installés, une seule fois.
87
+ Deux paquets qui déclarent le même identifiant : erreur explicite plutôt
88
+ qu'un choix silencieux de l'un des deux."""
89
+ global _discovered
90
+ if _discovered:
91
+ return
92
+ _discovered = True
93
+ for entry in entry_points(group=ENTRY_POINT_GROUP):
94
+ register(entry.name, _lazy(entry), package=entry.dist.name if entry.dist else None)
95
+
96
+
97
+ def _lazy(entry: EntryPoint) -> Callable[..., ParallelEnv]:
98
+ # La classe n'est importée qu'à la création d'un environnement : lister
99
+ # les environnements disponibles ne charge aucun moteur (le football
100
+ # charge un module natif).
101
+ def create(**kwargs: Any) -> ParallelEnv:
102
+ return entry.load()(**kwargs)
103
+
104
+ return create
@@ -0,0 +1,34 @@
1
+ """Format de rendu commun à tous les environnements (`render_mode="scene"`).
2
+
3
+ Un environnement ne dessine jamais lui-même : `render()` renvoie une scène
4
+ faite de primitives simples (rectangles, cercles, texte), sérialisable en
5
+ JSON telle quelle. N'importe quel afficheur — un script matplotlib local, la
6
+ page de match du site — peut ainsi dessiner n'importe quel environnement
7
+ sans rien connaître de ses règles.
8
+
9
+ Repère : origine en haut à gauche, x vers la droite, y vers le bas, dans les
10
+ unités propres à l'environnement (mètres pour le football, cases pour la
11
+ cuisine). `width`/`height` donnent l'étendue de la scène, pour la mise à
12
+ l'échelle. Les couleurs sont des chaînes CSS.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+
18
+ def scene(width: float, height: float, shapes: list[dict], background: str = "#ffffff") -> dict:
19
+ return {"width": float(width), "height": float(height), "background": background, "shapes": shapes}
20
+
21
+
22
+ def rect(x: float, y: float, w: float, h: float, fill: str) -> dict:
23
+ """Rectangle plein ; `(x, y)` est son coin haut-gauche."""
24
+ return {"type": "rect", "x": float(x), "y": float(y), "w": float(w), "h": float(h), "fill": fill}
25
+
26
+
27
+ def circle(x: float, y: float, r: float, fill: str) -> dict:
28
+ """Disque plein ; `(x, y)` est son centre."""
29
+ return {"type": "circle", "x": float(x), "y": float(y), "r": float(r), "fill": fill}
30
+
31
+
32
+ def text(x: float, y: float, value: str, fill: str = "#0f172a", size: float = 0.5) -> dict:
33
+ """Texte centré sur `(x, y)` ; `size` est la hauteur des caractères, dans les unités de la scène."""
34
+ return {"type": "text", "x": float(x), "y": float(y), "text": value, "fill": fill, "size": float(size)}
@@ -0,0 +1,130 @@
1
+ """Les tests du contrat commun, réutilisables par chaque paquet
2
+ d'environnement dans ses propres tests :
3
+
4
+ from frondori_engine.testing import check_environment
5
+
6
+ def test_contract():
7
+ check_environment("kitchen-v0")
8
+
9
+ Chaque vérification est aussi disponible seule (`check_*`). Elles lèvent
10
+ `AssertionError` (ou l'erreur de PettingZoo) au premier manquement, avec un
11
+ message qui dit quoi corriger.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+
18
+ from pettingzoo.test import parallel_api_test, parallel_seed_test
19
+
20
+ import frondori_engine
21
+ from frondori_engine import wire
22
+
23
+ RANKINGS = ("elo", "mean_return")
24
+ SHAPE_TYPES = {"rect", "circle", "text"}
25
+
26
+
27
+ def check_environment(env_id: str) -> None:
28
+ """Tout le contrat : à appeler depuis les tests de chaque environnement."""
29
+ check_pettingzoo_api(env_id)
30
+ check_reproducible(env_id)
31
+ check_observations_fit_their_space(env_id)
32
+ check_metadata(env_id)
33
+ check_spaces_can_be_sent(env_id)
34
+ check_neutral_action(env_id)
35
+ check_scene(env_id)
36
+
37
+
38
+ def check_pettingzoo_api(env_id: str) -> None:
39
+ """Le test officiel de PettingZoo (API parallèle). Il exige notamment
40
+ que `observation_space(agent)` renvoie le MÊME objet à chaque appel :
41
+ créer les spaces une fois, dans `__init__`."""
42
+ parallel_api_test(frondori_engine.make(env_id), num_cycles=300)
43
+
44
+
45
+ def check_reproducible(env_id: str) -> None:
46
+ """Même seed + mêmes actions = même épisode."""
47
+ parallel_seed_test(lambda: frondori_engine.make(env_id), num_cycles=100)
48
+
49
+
50
+ def check_observations_fit_their_space(env_id: str, steps: int = 500) -> None:
51
+ # `parallel_api_test` ne le vérifie PAS (constaté : il laisse passer une
52
+ # valeur hors bornes). Or c'est sur ces spaces que le serveur et les
53
+ # agents s'appuient : vérifié ici explicitement.
54
+ env = frondori_engine.make(env_id)
55
+ observations, _ = env.reset(seed=0)
56
+ for _ in range(steps):
57
+ for agent, observation in observations.items():
58
+ assert env.observation_space(agent).contains(observation), (
59
+ f"{env_id} : l'observation de {agent} sort de son observation_space : {observation!r}"
60
+ )
61
+ if not env.agents:
62
+ break
63
+ observations, *_ = env.step({agent: env.action_space(agent).sample() for agent in env.agents})
64
+
65
+
66
+ def check_metadata(env_id: str) -> None:
67
+ """Les métadonnées dont le serveur et le site ont besoin."""
68
+ env = frondori_engine.make(env_id)
69
+ metadata = env.metadata
70
+ for key in ("title", "description", "documentation"):
71
+ assert isinstance(metadata.get(key), str) and metadata[key].strip(), (
72
+ f"{env_id} : metadata[{key!r}] (texte non vide) est requis"
73
+ )
74
+ assert (metadata.get("render_fps") or 0) > 0, f"{env_id} : metadata['render_fps'] (pas par seconde) est requis"
75
+ assert (metadata.get("compute_budget_ms") or 0) > 0, (
76
+ f"{env_id} : metadata['compute_budget_ms'] (temps de calcul par action, en ms) est requis"
77
+ )
78
+ assert metadata.get("ranking") in RANKINGS, f"{env_id} : metadata['ranking'] doit valoir l'un de {RANKINGS}"
79
+ if metadata["ranking"] == "elo":
80
+ assert len(env.possible_agents) == 2, f"{env_id} : un classement ELO n'a de sens qu'en duel (2 agents)"
81
+ assert "scene" in metadata.get("render_modes", []), f"{env_id} : render_mode 'scene' est requis"
82
+
83
+
84
+ def check_spaces_can_be_sent(env_id: str) -> None:
85
+ """Seuls Box, Discrete, MultiDiscrete et Dict passent sur le réseau."""
86
+ env = frondori_engine.make(env_id)
87
+ for agent in env.possible_agents:
88
+ wire.space_to_spec(env.observation_space(agent))
89
+ wire.space_to_spec(env.action_space(agent))
90
+
91
+
92
+ def check_neutral_action(env_id: str) -> None:
93
+ """L'élément "zéro" de chaque action_space est une action valide : c'est
94
+ celle que le serveur joue pour un agent dont l'action est invalide, trop
95
+ lente ou absente (elle doit ne rien faire — ce que seul l'auteur de
96
+ l'environnement peut garantir)."""
97
+ env = frondori_engine.make(env_id)
98
+ env.reset(seed=0)
99
+ for agent in env.possible_agents:
100
+ space = env.action_space(agent)
101
+ assert space.contains(wire.neutral_action(space)), (
102
+ f"{env_id} : l'action neutre de {agent} sort de son action_space"
103
+ )
104
+ env.step({agent: wire.neutral_action(env.action_space(agent)) for agent in env.agents})
105
+
106
+
107
+ def check_scene(env_id: str) -> None:
108
+ """Le rendu générique : sérialisable en JSON, primitives connues."""
109
+ env = frondori_engine.make(env_id, render_mode="scene")
110
+ env.reset(seed=0)
111
+ env.step({agent: env.action_space(agent).sample() for agent in env.agents})
112
+ scene = env.render()
113
+
114
+ json.dumps(scene)
115
+ assert scene["width"] > 0 and scene["height"] > 0, f"{env_id} : la scène doit avoir une largeur et une hauteur"
116
+ assert scene["shapes"], f"{env_id} : la scène est vide"
117
+ unknown = {shape["type"] for shape in scene["shapes"]} - SHAPE_TYPES
118
+ assert not unknown, f"{env_id} : primitives de scène inconnues {unknown} (attendu : {SHAPE_TYPES})"
119
+
120
+
121
+ __all__ = [
122
+ "check_environment",
123
+ "check_metadata",
124
+ "check_neutral_action",
125
+ "check_observations_fit_their_space",
126
+ "check_pettingzoo_api",
127
+ "check_reproducible",
128
+ "check_scene",
129
+ "check_spaces_can_be_sent",
130
+ ]
@@ -0,0 +1,89 @@
1
+ """Passage entre les valeurs d'un environnement (numpy, spaces Gymnasium) et
2
+ leur forme sur le fil (types MessagePack natifs : nombres, listes, dicts).
3
+
4
+ Ce format fait partie du protocole de compétition : le SDK
5
+ (`frondori-sdk-python`) en implémente l'autre moitié (spec -> space, fil ->
6
+ numpy). Toute modification ici doit y être reportée.
7
+
8
+ Spaces supportés : Box, Discrete, MultiDiscrete, et Dict de ces spaces. Un
9
+ environnement qui en utilise un autre est refusé explicitement à la
10
+ description (`space_to_spec`), pas silencieusement mal transmis.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+ from gymnasium import spaces
17
+
18
+
19
+ def space_to_spec(space: spaces.Space) -> dict:
20
+ if isinstance(space, spaces.Box):
21
+ return {
22
+ "type": "box",
23
+ "shape": list(space.shape),
24
+ "dtype": space.dtype.name,
25
+ "low": space.low.tolist(),
26
+ "high": space.high.tolist(),
27
+ }
28
+ if isinstance(space, spaces.Discrete):
29
+ return {"type": "discrete", "n": int(space.n), "start": int(space.start)}
30
+ if isinstance(space, spaces.MultiDiscrete):
31
+ return {
32
+ "type": "multi_discrete",
33
+ "nvec": space.nvec.tolist(),
34
+ "start": space.start.tolist(),
35
+ "dtype": space.dtype.name,
36
+ }
37
+ if isinstance(space, spaces.Dict):
38
+ return {"type": "dict", "spaces": {key: space_to_spec(sub) for key, sub in space.spaces.items()}}
39
+ raise TypeError(f"space non supporté par le protocole : {type(space).__name__}")
40
+
41
+
42
+ def to_wire(value):
43
+ """numpy -> types natifs, récursivement (observations, infos)."""
44
+ if isinstance(value, np.ndarray):
45
+ return value.tolist()
46
+ if isinstance(value, np.generic):
47
+ return value.item()
48
+ if isinstance(value, dict):
49
+ return {str(key): to_wire(item) for key, item in value.items()}
50
+ if isinstance(value, (list, tuple)):
51
+ return [to_wire(item) for item in value]
52
+ return value
53
+
54
+
55
+ def from_wire(space: spaces.Space, value):
56
+ """Action reçue du fil -> valeur du space. Lève `ValueError` si elle n'y
57
+ appartient pas : un agent n'est pas de confiance, rien n'est passé à
58
+ l'environnement sans vérification."""
59
+ if isinstance(space, spaces.Dict):
60
+ if not isinstance(value, dict) or set(value) != set(space.spaces):
61
+ raise ValueError(f"attendu un dict avec les clés {sorted(space.spaces)}")
62
+ return {key: from_wire(sub, value[key]) for key, sub in space.spaces.items()}
63
+ if isinstance(space, spaces.Discrete):
64
+ # `bool` est un sous-type d'`int` en Python : refusé explicitement.
65
+ if isinstance(value, bool) or not isinstance(value, int):
66
+ raise ValueError("attendu un entier")
67
+ action = np.int64(value)
68
+ else:
69
+ action = np.asarray(value, dtype=space.dtype)
70
+ if not space.contains(action):
71
+ raise ValueError("valeur hors de l'action_space")
72
+ return action
73
+
74
+
75
+ def neutral_action(space: spaces.Space):
76
+ """Action appliquée à un agent qui n'a pas répondu à temps, ou dont
77
+ l'action est invalide : l'élément « zéro » du space (0 ramené dans les
78
+ bornes, ou la plus petite valeur discrète). Un environnement doit donc
79
+ être conçu pour que cet élément soit une action sans effet — c'est le cas
80
+ du football (ne pas bouger, ne pas tirer) et de la cuisine (rester)."""
81
+ if isinstance(space, spaces.Dict):
82
+ return {key: neutral_action(sub) for key, sub in space.spaces.items()}
83
+ if isinstance(space, spaces.Discrete):
84
+ return np.int64(space.start)
85
+ if isinstance(space, spaces.MultiDiscrete):
86
+ return np.array(space.start, dtype=space.dtype)
87
+ if isinstance(space, spaces.Box):
88
+ return np.clip(np.zeros(space.shape), space.low, space.high).astype(space.dtype)
89
+ raise TypeError(f"space non supporté par le protocole : {type(space).__name__}")
@@ -0,0 +1,159 @@
1
+ """Exécute un environnement dans un processus dédié, piloté par le serveur de
2
+ compétition (`frondori-server`) via l'entrée et la sortie standard.
3
+
4
+ Le serveur lance un worker par match (`python -m frondori_engine.worker`) :
5
+ un environnement qui plante ou se bloque n'emporte que son propre match, et
6
+ le serveur n'a pas à savoir en quel langage l'environnement est écrit.
7
+
8
+ Chaque message est un objet MessagePack précédé de sa taille (4 octets,
9
+ big-endian), dans les deux sens. Requêtes, selon `cmd` :
10
+ - `describe` : catalogue des environnements (agents, cadence, budget de
11
+ calcul, classement, spaces) ;
12
+ - `start` (`env_id`, `seed`) : crée l'environnement et renvoie les
13
+ observations initiales ;
14
+ - `step` (`actions` : agent -> action, ou `nil` si l'agent n'a pas répondu à
15
+ temps) : un pas de simulation.
16
+ Réponses : `{"ok": true, ...}` ou `{"ok": false, "error": "..."}`.
17
+
18
+ Une action absente ou invalide est remplacée par l'action neutre de son
19
+ space (`wire.neutral_action`) ; les agents dont l'action a été refusée sont
20
+ listés dans `rejected`.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import struct
28
+ import sys
29
+ import traceback
30
+
31
+ import msgpack
32
+
33
+ import frondori_engine
34
+ from frondori_engine import wire
35
+
36
+ # Comment la plateforme classe les agents d'un environnement (déclaré dans
37
+ # `metadata["ranking"]`) : `elo` pour un duel (le vainqueur est l'agent au
38
+ # meilleur retour), `mean_return` pour le reste (coopératif, solo) — le
39
+ # retour moyen de l'agent par match.
40
+ RANKINGS = ("elo", "mean_return")
41
+
42
+
43
+ def main() -> None:
44
+ # La sortie standard porte le protocole : un `print` égaré dans le code
45
+ # d'un environnement la corromprait. On garde une copie privée du vrai
46
+ # stdout pour les réponses, et tout le reste part sur stderr.
47
+ protocol_out = os.fdopen(os.dup(1), "wb")
48
+ os.dup2(2, 1)
49
+ sys.stdout = sys.stderr
50
+
51
+ session = _Session()
52
+ while (request := _read_frame(sys.stdin.buffer)) is not None:
53
+ try:
54
+ reply = {"ok": True, **session.handle(request)}
55
+ except Exception:
56
+ reply = {"ok": False, "error": traceback.format_exc()}
57
+ _write_frame(protocol_out, reply)
58
+
59
+
60
+ class _Session:
61
+ def __init__(self) -> None:
62
+ self._env = None
63
+
64
+ def handle(self, request: dict) -> dict:
65
+ command = request.get("cmd")
66
+ if command == "describe":
67
+ return {"environments": {env_id: _describe(env_id) for env_id in frondori_engine.registered_ids()}}
68
+ if command == "start":
69
+ return self._start(request["env_id"], request["seed"])
70
+ if command == "step":
71
+ return self._step(request["actions"])
72
+ raise ValueError(f"commande inconnue : {command!r}")
73
+
74
+ def _start(self, env_id: str, seed: int) -> dict:
75
+ self._env = frondori_engine.make(env_id, render_mode="scene")
76
+ observations, infos = self._env.reset(seed=seed)
77
+ return {
78
+ "agents": list(self._env.possible_agents),
79
+ "observations": wire.to_wire(observations),
80
+ "infos": wire.to_wire(infos),
81
+ "scene": self._scene(),
82
+ }
83
+
84
+ def _step(self, received: dict) -> dict:
85
+ actions, rejected = {}, []
86
+ for agent in self._env.agents:
87
+ space = self._env.action_space(agent)
88
+ value = received.get(agent)
89
+ try:
90
+ if value is None:
91
+ raise ValueError("pas d'action")
92
+ actions[agent] = wire.from_wire(space, value)
93
+ except (ValueError, TypeError):
94
+ actions[agent] = wire.neutral_action(space)
95
+ if value is not None:
96
+ rejected.append(agent)
97
+
98
+ observations, rewards, terminations, truncations, infos = self._env.step(actions)
99
+ return {
100
+ "observations": wire.to_wire(observations),
101
+ "rewards": {agent: float(reward) for agent, reward in rewards.items()},
102
+ "terminations": {agent: bool(done) for agent, done in terminations.items()},
103
+ "truncations": {agent: bool(done) for agent, done in truncations.items()},
104
+ "infos": wire.to_wire(infos),
105
+ "scene": self._scene(),
106
+ "rejected": rejected,
107
+ }
108
+
109
+ def _scene(self) -> str:
110
+ # Déjà en JSON : le serveur la relaie telle quelle aux spectateurs et
111
+ # la concatène dans le replay, sans jamais avoir à la décoder.
112
+ return json.dumps(self._env.render(), separators=(",", ":"))
113
+
114
+
115
+ def _describe(env_id: str) -> dict:
116
+ env = frondori_engine.make(env_id)
117
+ tick_rate = env.metadata.get("render_fps")
118
+ if not tick_rate or tick_rate <= 0:
119
+ raise ValueError(f"{env_id} : metadata['render_fps'] (cadence en pas/seconde) est requis")
120
+ ranking = env.metadata.get("ranking")
121
+ if ranking not in RANKINGS:
122
+ raise ValueError(f"{env_id} : metadata['ranking'] doit valoir l'un de {RANKINGS}")
123
+ if ranking == "elo" and len(env.possible_agents) != 2:
124
+ raise ValueError(f"{env_id} : un classement ELO n'a de sens qu'en duel (2 agents)")
125
+ budget = env.metadata.get("compute_budget_ms")
126
+ if not budget or budget <= 0:
127
+ raise ValueError(f"{env_id} : metadata['compute_budget_ms'] (temps de calcul par action, en ms) est requis")
128
+ return {
129
+ "agents": list(env.possible_agents),
130
+ "tick_rate": float(tick_rate),
131
+ "ranking": ranking,
132
+ "compute_budget_ms": float(budget),
133
+ "title": env.metadata.get("title", env_id),
134
+ "description": env.metadata.get("description", ""),
135
+ "documentation": env.metadata.get("documentation", ""),
136
+ # Le paquet à installer pour jouer à cet environnement en local
137
+ # (le site affiche la commande `pip install`).
138
+ "package": frondori_engine.get_spec(env_id).package or "",
139
+ "observation_spaces": {agent: wire.space_to_spec(env.observation_space(agent)) for agent in env.possible_agents},
140
+ "action_spaces": {agent: wire.space_to_spec(env.action_space(agent)) for agent in env.possible_agents},
141
+ }
142
+
143
+
144
+ def _read_frame(stream):
145
+ header = stream.read(4)
146
+ if len(header) < 4:
147
+ return None
148
+ (size,) = struct.unpack(">I", header)
149
+ return msgpack.unpackb(stream.read(size), raw=False)
150
+
151
+
152
+ def _write_frame(stream, message: dict) -> None:
153
+ payload = msgpack.packb(message, use_bin_type=True)
154
+ stream.write(struct.pack(">I", len(payload)) + payload)
155
+ stream.flush()
156
+
157
+
158
+ if __name__ == "__main__":
159
+ main()
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: frondori-engine
3
+ Version: 0.4.0
4
+ Summary: Frondori research environments: the common contract (PettingZoo API), registry, rendering format and contract tests. Environments are installed separately (frondori-football, frondori-kitchen...).
5
+ Author: Melvine Nargeot
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://frondori.com
8
+ Project-URL: Documentation, https://frondori.com/documentation/train-locally
9
+ Project-URL: Source, https://github.com/Melvin-klein/frondori-engine
10
+ Project-URL: Issues, https://github.com/Melvin-klein/frondori-engine/issues
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Operating System :: OS Independent
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: numpy>=1.24
25
+ Requires-Dist: gymnasium>=1.0
26
+ Requires-Dist: pettingzoo>=1.24
27
+ Requires-Dist: msgpack>=1.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # frondori-engine
33
+
34
+ Le contrat commun des environnements de recherche de
35
+ Frondori, au format [PettingZoo](https://pettingzoo.farama.org/).
36
+
37
+ **Ce paquet ne contient aucun environnement.** Chacun est un paquet à part,
38
+ dans son propre dépôt, qu'on installe seulement s'il nous intéresse :
39
+
40
+ | Paquet | Environnement |
41
+ |---|---|
42
+ | `frondori-football` | `football-v0` — football 2D, duel (moteur Rust) |
43
+ | `frondori-kitchen` | `kitchen-v0` — cuisine coopérative |
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install frondori-engine frondori-kitchen
49
+ ```
50
+
51
+ (Pas encore publiés sur PyPI : depuis un clone, `pip install -e .` dans
52
+ chaque dépôt.)
53
+
54
+ ## Utilisation
55
+
56
+ ```python
57
+ import frondori_engine
58
+
59
+ print(frondori_engine.registered_ids()) # les environnements installés
60
+ env = frondori_engine.make("kitchen-v0") # un pettingzoo.ParallelEnv
61
+ observations, infos = env.reset(seed=42)
62
+
63
+ while env.agents:
64
+ actions = {agent: env.action_space(agent).sample() for agent in env.agents}
65
+ observations, rewards, terminations, truncations, infos = env.step(actions)
66
+ ```
67
+
68
+ Sans serveur, sans réseau, sans token. Compatible avec l'écosystème qui parle
69
+ PettingZoo (RLlib, TorchRL, CleanRL, SuperSuit...). Pour jouer un match en
70
+ local dans les conditions de la compétition, ou contre d'autres agents sur
71
+ le serveur, voir le SDK (`frondori-sdk`) : `Agent(..., local=True)`.
72
+
73
+ ## Ce que contient ce paquet
74
+
75
+ - `frondori_engine.make` / `registered_ids` / `register` : le registre. Les
76
+ environnements installés sont découverts automatiquement par leur *entry
77
+ point* (groupe `frondori.environments`).
78
+ - `frondori_engine.scene` : le format de rendu commun (`render_mode="scene"`).
79
+ - `frondori_engine.wire` : le format des spaces et des valeurs sur le réseau.
80
+ - `frondori_engine.worker` : exécute un environnement pour le serveur de
81
+ compétition (un processus par match).
82
+ - `frondori_engine.testing` : les tests du contrat, à appeler depuis les
83
+ tests de chaque environnement (`check_environment("mon-jeu-v0")`).
84
+
85
+ ## Créer un environnement
86
+
87
+ Un paquet Python qui dépend de `frondori-engine` et se déclare dans son
88
+ `pyproject.toml` :
89
+
90
+ ```toml
91
+ [project.entry-points."frondori.environments"]
92
+ "mon-jeu-v0" = "mon_paquet:MonJeuEnv"
93
+ ```
94
+
95
+ Guide complet, avec un exemple vérifié : Documentation > Create an
96
+ Environment, sur le site. L'exemple de ce guide (`tests/rps.py`) sert aussi
97
+ d'environnement de test à ce paquet.
98
+
99
+ ## Développer / tester
100
+
101
+ ```bash
102
+ python -m venv .venv && source .venv/bin/activate
103
+ pip install -e ".[dev]"
104
+ python -m pytest
105
+ ```
106
+
107
+ ## Publier une version
108
+
109
+ 1. Mettre à jour `version` dans `pyproject.toml` et commiter.
110
+ 2. Pousser un tag du même numéro : `git tag v0.1.0 && git push origin v0.1.0`.
111
+
112
+ La CI (`.github/workflows/ci.yml`) teste, construit et publie sur PyPI ; elle
113
+ refuse un tag qui ne correspond pas à la version. Publication par *Trusted
114
+ Publishing*, sans token : à configurer une fois sur PyPI (projet `frondori-engine` >
115
+ Publishing > trusted publisher GitHub : ce dépôt, workflow `ci.yml`,
116
+ environnement `pypi`).
117
+
118
+ À publier EN PREMIER : les environnements et le SDK en dépendent.
119
+
120
+ Licence : MIT.
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ frondori_engine/__init__.py
5
+ frondori_engine/registry.py
6
+ frondori_engine/scene.py
7
+ frondori_engine/testing.py
8
+ frondori_engine/wire.py
9
+ frondori_engine/worker.py
10
+ frondori_engine.egg-info/PKG-INFO
11
+ frondori_engine.egg-info/SOURCES.txt
12
+ frondori_engine.egg-info/dependency_links.txt
13
+ frondori_engine.egg-info/requires.txt
14
+ frondori_engine.egg-info/top_level.txt
15
+ tests/test_registry.py
16
+ tests/test_testing.py
17
+ tests/test_worker.py
@@ -0,0 +1,7 @@
1
+ numpy>=1.24
2
+ gymnasium>=1.0
3
+ pettingzoo>=1.24
4
+ msgpack>=1.0
5
+
6
+ [dev]
7
+ pytest>=7
@@ -0,0 +1 @@
1
+ frondori_engine
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "frondori-engine"
7
+ version = "0.4.0"
8
+ description = "Frondori research environments: the common contract (PettingZoo API), registry, rendering format and contract tests. Environments are installed separately (frondori-football, frondori-kitchen...)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Melvine Nargeot" }]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "Intended Audience :: Education",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Operating System :: OS Independent",
25
+ ]
26
+ dependencies = [
27
+ "numpy>=1.24",
28
+ "gymnasium>=1.0",
29
+ "pettingzoo>=1.24",
30
+ "msgpack>=1.0",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://frondori.com"
35
+ Documentation = "https://frondori.com/documentation/train-locally"
36
+ Source = "https://github.com/Melvin-klein/frondori-engine"
37
+ Issues = "https://github.com/Melvin-klein/frondori-engine/issues"
38
+
39
+ [project.optional-dependencies]
40
+ dev = ["pytest>=7"]
41
+
42
+ [tool.setuptools]
43
+ packages = ["frondori_engine"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,72 @@
1
+ from importlib.metadata import EntryPoint
2
+
3
+ import pytest
4
+
5
+ import frondori_engine
6
+ from frondori_engine import registry
7
+ from rps import RockPaperScissorsEnv
8
+
9
+
10
+ def test_make_passes_kwargs_to_the_environment():
11
+ env = frondori_engine.make("rps-v0", rounds=3)
12
+ env.reset()
13
+
14
+ for _ in range(3):
15
+ *_, truncations, _ = env.step({agent: 1 for agent in env.agents})
16
+
17
+ assert all(truncations.values())
18
+ assert env.agents == []
19
+
20
+
21
+ def test_unknown_environment_lists_available_ones():
22
+ with pytest.raises(KeyError, match="rps-v0"):
23
+ frondori_engine.make("chess-v0")
24
+
25
+
26
+ @pytest.mark.parametrize("bad_id", ["rps", "Rps-v0", "rps-v", "rps_v0"])
27
+ def test_ids_must_be_versioned(bad_id):
28
+ with pytest.raises(ValueError, match="nom-vN"):
29
+ frondori_engine.register(bad_id, RockPaperScissorsEnv)
30
+
31
+
32
+ def test_an_id_cannot_be_registered_twice():
33
+ with pytest.raises(ValueError, match="déjà enregistré"):
34
+ frondori_engine.register("rps-v0", RockPaperScissorsEnv)
35
+
36
+
37
+ def fake_entry_points(*entries):
38
+ return lambda group: [EntryPoint(name, value, group) for name, value in entries if group == registry.ENTRY_POINT_GROUP]
39
+
40
+
41
+ def test_installed_environments_are_discovered(monkeypatch):
42
+ monkeypatch.setattr(registry, "_registry", {})
43
+ monkeypatch.setattr(registry, "_discovered", False)
44
+ monkeypatch.setattr(registry, "entry_points", fake_entry_points(("game-v0", "rps:RockPaperScissorsEnv")))
45
+
46
+ assert frondori_engine.registered_ids() == ["game-v0"]
47
+ env = frondori_engine.make("game-v0", rounds=2)
48
+ assert isinstance(env, RockPaperScissorsEnv) and env.rounds == 2
49
+
50
+
51
+ def test_an_environment_class_is_only_imported_when_created(monkeypatch):
52
+ # Lister le catalogue ne doit charger aucun moteur.
53
+ monkeypatch.setattr(registry, "_registry", {})
54
+ monkeypatch.setattr(registry, "_discovered", False)
55
+ monkeypatch.setattr(registry, "entry_points", fake_entry_points(("broken-v0", "no_such_module:Env")))
56
+
57
+ assert frondori_engine.registered_ids() == ["broken-v0"]
58
+ with pytest.raises(ModuleNotFoundError):
59
+ frondori_engine.make("broken-v0")
60
+
61
+
62
+ def test_two_packages_declaring_the_same_id_is_an_error(monkeypatch):
63
+ monkeypatch.setattr(registry, "_registry", {})
64
+ monkeypatch.setattr(registry, "_discovered", False)
65
+ monkeypatch.setattr(
66
+ registry,
67
+ "entry_points",
68
+ fake_entry_points(("game-v0", "rps:RockPaperScissorsEnv"), ("game-v0", "rps:RockPaperScissorsEnv")),
69
+ )
70
+
71
+ with pytest.raises(ValueError, match="déjà enregistré"):
72
+ frondori_engine.registered_ids()
@@ -0,0 +1,67 @@
1
+ """Les vérifications du contrat, telles que les paquets d'environnement les
2
+ utilisent : elles passent sur un environnement conforme, et attrapent les
3
+ erreurs courantes avec un message utile."""
4
+
5
+ import numpy as np
6
+ import pytest
7
+ from gymnasium import spaces
8
+
9
+ import frondori_engine
10
+ from frondori_engine.testing import (
11
+ check_environment,
12
+ check_metadata,
13
+ check_observations_fit_their_space,
14
+ check_pettingzoo_api,
15
+ )
16
+ from rps import RockPaperScissorsEnv
17
+
18
+
19
+ def test_a_conforming_environment_passes_the_whole_contract():
20
+ check_environment("rps-v0")
21
+
22
+
23
+ class SpacesRecreatedOnEveryCall(RockPaperScissorsEnv):
24
+ def action_space(self, agent):
25
+ return spaces.Discrete(4)
26
+
27
+
28
+ class ObservationOutOfItsSpace(RockPaperScissorsEnv):
29
+ def _observations(self):
30
+ observations = super()._observations()
31
+ for observation in observations.values():
32
+ observation["rounds_left"] = 1000
33
+ return observations
34
+
35
+
36
+ class EloWithThreeAgents(RockPaperScissorsEnv):
37
+ def __init__(self, **kwargs):
38
+ super().__init__(**kwargs)
39
+ self.possible_agents = ["player_0", "player_1", "player_2"]
40
+
41
+
42
+ class NoBudget(RockPaperScissorsEnv):
43
+ metadata = {**RockPaperScissorsEnv.metadata, "compute_budget_ms": None}
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ ("broken", "check", "message"),
48
+ [
49
+ (SpacesRecreatedOnEveryCall, check_pettingzoo_api, "same space object"),
50
+ (ObservationOutOfItsSpace, check_observations_fit_their_space, "sort de son observation_space"),
51
+ (EloWithThreeAgents, check_metadata, "duel"),
52
+ (NoBudget, check_metadata, "compute_budget_ms"),
53
+ ],
54
+ )
55
+ def test_common_mistakes_are_caught(broken, check, message):
56
+ frondori_engine.register("broken-v0", broken)
57
+
58
+ with pytest.raises(AssertionError, match=message):
59
+ check("broken-v0")
60
+
61
+
62
+ def test_the_neutral_action_is_the_zero_of_the_action_space():
63
+ from frondori_engine import wire
64
+
65
+ assert wire.neutral_action(spaces.Discrete(4)) == 0
66
+ box = wire.neutral_action(spaces.Box(-1, 1, shape=(2, 3), dtype=np.float32))
67
+ assert box.shape == (2, 3) and not box.any()
@@ -0,0 +1,122 @@
1
+ """Le worker, testé comme le serveur l'utilise : un vrai sous-processus,
2
+ piloté en MessagePack par son entrée et sa sortie standard. Il joue
3
+ l'environnement d'exemple (`tests/rps.py`), enregistré avant de démarrer."""
4
+
5
+ import json
6
+ import struct
7
+ import subprocess
8
+ import sys
9
+ import textwrap
10
+ from pathlib import Path
11
+
12
+ import msgpack
13
+ import pytest
14
+
15
+ TESTS = Path(__file__).parent
16
+
17
+ BOOTSTRAP = textwrap.dedent(f"""
18
+ import sys
19
+ sys.path.insert(0, {str(TESTS)!r})
20
+ import frondori_engine
21
+ from rps import RockPaperScissorsEnv
22
+ frondori_engine.register("rps-v0", RockPaperScissorsEnv, package="frondori-rps")
23
+ {{extra}}
24
+ from frondori_engine.worker import main
25
+ main()
26
+ """)
27
+
28
+
29
+ class Worker:
30
+ def __init__(self, extra: str = ""):
31
+ self._process = subprocess.Popen(
32
+ [sys.executable, "-c", BOOTSTRAP.replace("{extra}", textwrap.dedent(extra))],
33
+ stdin=subprocess.PIPE,
34
+ stdout=subprocess.PIPE,
35
+ )
36
+
37
+ def request(self, message: dict) -> dict:
38
+ payload = msgpack.packb(message, use_bin_type=True)
39
+ self._process.stdin.write(struct.pack(">I", len(payload)) + payload)
40
+ self._process.stdin.flush()
41
+ (size,) = struct.unpack(">I", self._process.stdout.read(4))
42
+ return msgpack.unpackb(self._process.stdout.read(size), raw=False)
43
+
44
+ def close(self) -> None:
45
+ self._process.stdin.close()
46
+ assert self._process.wait(timeout=10) == 0
47
+
48
+
49
+ @pytest.fixture
50
+ def worker():
51
+ w = Worker()
52
+ yield w
53
+ w.close()
54
+
55
+
56
+ def test_describe_gives_what_the_server_and_the_website_need(worker):
57
+ reply = worker.request({"cmd": "describe"})
58
+
59
+ assert reply["ok"]
60
+ rps = reply["environments"]["rps-v0"]
61
+ assert rps["agents"] == ["player_0", "player_1"]
62
+ assert rps["tick_rate"] == 10.0
63
+ assert rps["ranking"] == "elo"
64
+ assert rps["compute_budget_ms"] == 50.0
65
+ assert rps["title"] == "Rock Paper Scissors"
66
+ assert rps["documentation"]
67
+ assert rps["package"] == "frondori-rps"
68
+ assert rps["action_spaces"]["player_0"] == {"type": "discrete", "n": 4, "start": 0}
69
+ assert rps["observation_spaces"]["player_0"]["type"] == "dict"
70
+
71
+
72
+ def test_an_episode_runs_through_the_worker(worker):
73
+ start = worker.request({"cmd": "start", "env_id": "rps-v0", "seed": 0})
74
+
75
+ assert start["ok"]
76
+ assert start["agents"] == ["player_0", "player_1"]
77
+ assert start["observations"]["player_0"] == {"opponent_last": 0, "rounds_left": 100}
78
+ json.loads(start["scene"])
79
+
80
+ # player_0 joue la pierre (1), player_1 n'a pas répondu à temps (nil).
81
+ step = worker.request({"cmd": "step", "actions": {"player_0": 1, "player_1": None}})
82
+
83
+ assert step["ok"]
84
+ assert step["rejected"] == []
85
+ assert step["rewards"] == {"player_0": 1.0, "player_1": -1.0}
86
+ assert step["observations"]["player_1"]["opponent_last"] == 1
87
+ assert step["infos"]["player_0"] == {"score": 1}
88
+
89
+
90
+ def test_invalid_actions_are_replaced_by_the_neutral_action(worker):
91
+ worker.request({"cmd": "start", "env_id": "rps-v0", "seed": 0})
92
+
93
+ step = worker.request({"cmd": "step", "actions": {"player_0": 42, "player_1": "n'importe quoi"}})
94
+
95
+ assert step["ok"]
96
+ assert sorted(step["rejected"]) == ["player_0", "player_1"]
97
+ # Les deux ont "passé" (action neutre 0) : manche nulle.
98
+ assert step["rewards"] == {"player_0": 0.0, "player_1": 0.0}
99
+
100
+
101
+ def test_an_error_is_reported_without_killing_the_worker(worker):
102
+ reply = worker.request({"cmd": "start", "env_id": "chess-v0", "seed": 0})
103
+
104
+ assert not reply["ok"]
105
+ assert "chess-v0" in reply["error"]
106
+ assert worker.request({"cmd": "describe"})["ok"]
107
+
108
+
109
+ def test_a_stray_print_in_an_environment_does_not_corrupt_the_protocol():
110
+ worker = Worker("""
111
+ class Noisy(RockPaperScissorsEnv):
112
+ def reset(self, seed=None, options=None):
113
+ print("un print oublié dans un environnement")
114
+ return super().reset(seed=seed, options=options)
115
+
116
+ frondori_engine.register("noisy-v0", Noisy)
117
+ """)
118
+
119
+ reply = worker.request({"cmd": "start", "env_id": "noisy-v0", "seed": 0})
120
+
121
+ assert reply["ok"]
122
+ worker.close()