env-ssl-wrapper 0.5.6__tar.gz → 0.5.7__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.
Files changed (27) hide show
  1. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/.gitignore +3 -0
  2. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/PKG-INFO +27 -2
  3. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/README.md +26 -1
  4. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/__init__.py +2 -0
  5. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/sgs.py +132 -26
  6. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/helpers.py +3 -0
  7. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/pyproject.toml +1 -1
  8. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/LICENSE +0 -0
  9. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/action_chunk.py +0 -0
  10. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/evaluate.py +0 -0
  11. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/memory_trace.py +0 -0
  12. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/__init__.py +0 -0
  13. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/action_transform_wrapper.py +0 -0
  14. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/adapters.py +0 -0
  15. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/auto_batched_wrapper.py +0 -0
  16. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/done_tracker_wrapper.py +0 -0
  17. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/episode_padding_wrapper.py +0 -0
  18. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/flatten_obs_wrapper.py +0 -0
  19. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/image_wrapper.py +0 -0
  20. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/mocks.py +0 -0
  21. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/spaces.py +0 -0
  22. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/standardize_env_wrapper.py +0 -0
  23. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/standardize_wrapper.py +0 -0
  24. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/tensor_wrapper.py +0 -0
  25. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/time_limit_wrapper.py +0 -0
  26. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/utils.py +0 -0
  27. {env_ssl_wrapper-0.5.6 → env_ssl_wrapper-0.5.7}/env_ssl_wrapper/standardize/vector.py +0 -0
@@ -207,3 +207,6 @@ cython_debug/
207
207
  marimo/_static/
208
208
  marimo/_lsp/
209
209
  __marimo__/
210
+
211
+ # Experiment artifacts (charts, checkpoints, progression logs)
212
+ experiments/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: env-ssl-wrapper
3
- Version: 0.5.6
3
+ Version: 0.5.7
4
4
  Summary: One torch-native interface for any MDP environment
5
5
  Project-URL: Homepage, https://pypi.org/project/env-ssl-wrapper/
6
6
  Project-URL: Repository, https://codeberg.org/lucidrains/env-ssl-wrapper
@@ -198,7 +198,32 @@ obs, info = env.reset()
198
198
  # info['sgs_config_id'] -> the config this episode started from (success rates update on episode end)
199
199
  ```
200
200
 
201
- `reset_configs` holds reset states — or callables drawing one (`lambda rng: ...`) — passed through `options`, or built by a `reset_options_fn` / `state_transform`. Every stage of the sampling loop is hookable: score equation (`score_fn`), target schedule, uniform floor (`uniform_prob`), env-native fallback (`default_prob`), success signal (`success_fn`).
201
+ `reset_configs` holds reset states — or callables drawing one (`lambda rng: ...`) — passed through `options`, or built by a `reset_options_fn` / `state_transform`.
202
+
203
+ The score equation is swappable: the default beta shaped kernel around `target` (`sgs_score_fn`) is simply one rule of the bank's state. Whatever you pass as `score_fn` is handed, by name, the arguments it declares — `p` (per state success rates), `counts`, `history` (the raw episode outcomes), `step`, `window`, and the hparams `target`, `kappa`, `eps`, `unvisited_weight`.
204
+
205
+ The sampling distribution is swappable too: the default (`sgs_sample_fn`) draws from the beta shape the score produces, while a custom `sample_fn` is handed, by name, whatever it declares — `p`, `counts`, `history`, `configs`, `weights`, `probs`, `rng`, `size`, `step`, and the hparams — so any distribution of the bank can be plugged in.
206
+
207
+ The rest of the loop is hookable as well: target schedule, uniform floor (`uniform_prob`), env-native fallback (`default_prob`), success signal (`success_fn`).
208
+
209
+ Track the curriculum as it runs — one snapshot of the bank per credited episode, kept in memory and appended to a csv when `log_path` is set:
210
+
211
+ ```python
212
+ env = SuccessGuidedSamplingWrapper(
213
+ StandardizeEnvWrapper(gym.make('CartPole-v1')),
214
+ reset_configs = reset_states,
215
+ log_every = 1, # snapshot every episode (None turns logging off)
216
+ log_path = 'sgs_progression.csv',
217
+ )
218
+
219
+ env.trace # [dict(step, success_rates, probs), ...]
220
+ ```
221
+
222
+ LunarLander is the worked example — a difficulty ladder of reset states, three arms (native spawns, uniform bank resets, SGS), paired evaluation and the speedup chart:
223
+
224
+ ```bash
225
+ uv run train_lunar_sgs.py --steps=200000 --seeds=0,1,2,3,4 # writes experiments/lunar_sgs/
226
+ ```
202
227
 
203
228
  ## Wrappers
204
229
 
@@ -138,7 +138,32 @@ obs, info = env.reset()
138
138
  # info['sgs_config_id'] -> the config this episode started from (success rates update on episode end)
139
139
  ```
140
140
 
141
- `reset_configs` holds reset states — or callables drawing one (`lambda rng: ...`) — passed through `options`, or built by a `reset_options_fn` / `state_transform`. Every stage of the sampling loop is hookable: score equation (`score_fn`), target schedule, uniform floor (`uniform_prob`), env-native fallback (`default_prob`), success signal (`success_fn`).
141
+ `reset_configs` holds reset states — or callables drawing one (`lambda rng: ...`) — passed through `options`, or built by a `reset_options_fn` / `state_transform`.
142
+
143
+ The score equation is swappable: the default beta shaped kernel around `target` (`sgs_score_fn`) is simply one rule of the bank's state. Whatever you pass as `score_fn` is handed, by name, the arguments it declares — `p` (per state success rates), `counts`, `history` (the raw episode outcomes), `step`, `window`, and the hparams `target`, `kappa`, `eps`, `unvisited_weight`.
144
+
145
+ The sampling distribution is swappable too: the default (`sgs_sample_fn`) draws from the beta shape the score produces, while a custom `sample_fn` is handed, by name, whatever it declares — `p`, `counts`, `history`, `configs`, `weights`, `probs`, `rng`, `size`, `step`, and the hparams — so any distribution of the bank can be plugged in.
146
+
147
+ The rest of the loop is hookable as well: target schedule, uniform floor (`uniform_prob`), env-native fallback (`default_prob`), success signal (`success_fn`).
148
+
149
+ Track the curriculum as it runs — one snapshot of the bank per credited episode, kept in memory and appended to a csv when `log_path` is set:
150
+
151
+ ```python
152
+ env = SuccessGuidedSamplingWrapper(
153
+ StandardizeEnvWrapper(gym.make('CartPole-v1')),
154
+ reset_configs = reset_states,
155
+ log_every = 1, # snapshot every episode (None turns logging off)
156
+ log_path = 'sgs_progression.csv',
157
+ )
158
+
159
+ env.trace # [dict(step, success_rates, probs), ...]
160
+ ```
161
+
162
+ LunarLander is the worked example — a difficulty ladder of reset states, three arms (native spawns, uniform bank resets, SGS), paired evaluation and the speedup chart:
163
+
164
+ ```bash
165
+ uv run train_lunar_sgs.py --steps=200000 --seeds=0,1,2,3,4 # writes experiments/lunar_sgs/
166
+ ```
142
167
 
143
168
  ## Wrappers
144
169
 
@@ -12,6 +12,7 @@ from .sgs import (
12
12
  SGSWrapper,
13
13
  SGSSampler,
14
14
  sgs_score_fn,
15
+ sgs_sample_fn,
15
16
  variance_score_fn,
16
17
  uniform_score_fn,
17
18
  )
@@ -50,6 +51,7 @@ __all__ = [
50
51
  'SGSWrapper',
51
52
  'SGSSampler',
52
53
  'sgs_score_fn',
54
+ 'sgs_sample_fn',
53
55
  'variance_score_fn',
54
56
  'uniform_score_fn',
55
57
  'evaluate_actor',
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from collections import deque
4
4
  import inspect
5
+ import os
6
+ from pathlib import Path
5
7
  from typing import Callable, Sequence
6
8
 
7
9
  import numpy as np
@@ -14,6 +16,7 @@ from .standardize.helpers import (
14
16
  any_true,
15
17
  copy_leaf,
16
18
  default,
19
+ divisible_by,
17
20
  dones_of,
18
21
  env_autoresets,
19
22
  env_num_envs,
@@ -48,6 +51,19 @@ def call_hook(fn: Callable, *args):
48
51
  def resolve_schedule(val: float | Callable[[int], float], step: int) -> float:
49
52
  return float(val(step)) if callable(val) else float(val)
50
53
 
54
+ def inject_args(fn: Callable, available: dict) -> dict:
55
+ # dependency injection - hand the callable exactly the arguments it declares
56
+
57
+ try:
58
+ params = inspect.signature(fn).parameters
59
+ except (TypeError, ValueError):
60
+ return dict(available)
61
+
62
+ if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values()):
63
+ return dict(available)
64
+
65
+ return {name: value for name, value in available.items() if name in params}
66
+
51
67
  def default_success_fn(obs, reward, terminated, truncated, info):
52
68
  if isinstance(info, dict):
53
69
  succ = first_existing(info, 'is_success', 'success')
@@ -144,12 +160,34 @@ def uniform_score_fn(
144
160
  ) -> torch.Tensor:
145
161
  return torch.ones_like(p, dtype = torch.float32)
146
162
 
163
+ # default sampling function
164
+
165
+ def sgs_sample_fn(
166
+ probs: torch.Tensor,
167
+ rng: np.random.Generator,
168
+ size: int = 1
169
+ ) -> np.ndarray:
170
+ # the paper's default - categorical draw over the beta shaped probs (see `sgs_score_fn`)
171
+
172
+ probs_np = to_numpy(probs).astype(np.float64)
173
+
174
+ return rng.choice(len(probs_np), size = size, p = probs_np / probs_np.sum())
175
+
147
176
  # sampler
148
177
 
149
178
  class SGSSampler:
150
179
  """
151
180
  Success-Guided Sampling (SGS) curriculum sampler over a bank of reset configurations.
152
181
  Proposed by Octi Zhang et al. in OmniReset (https://arxiv.org/abs/2603.15999).
182
+
183
+ The default sampling distribution is the beta kernel around `target` evaluated at the
184
+ success rates (`sgs_score_fn`), drawn by `sgs_sample_fn`. Swap either hook - `score_fn`
185
+ reshapes the scores, `sample_fn` picks the distribution - each is handed, by name,
186
+ whatever it declares of `p`, `counts`, `history`, `configs`, `weights`, `probs`, `rng`,
187
+ `size`, `step`, `window` and the hparams.
188
+
189
+ Set `log_every` to keep snapshots of the bank in `self.trace` as episodes are credited,
190
+ and `log_path` to also append them to a csv (step, success rates, sampling probs).
153
191
  """
154
192
 
155
193
  def __init__(
@@ -163,13 +201,17 @@ class SGSSampler:
163
201
  uniform_prob: float | Callable[[int], float] = 0.,
164
202
  default_prob: float | Callable[[int], float] = 0.,
165
203
  score_fn: Callable | None = None,
204
+ sample_fn: Callable | None = None,
166
205
  state_transform: Callable | None = None,
206
+ log_every: int | None = None,
207
+ log_path: str | os.PathLike | None = None,
167
208
  seed: int | None = None,
168
209
  ):
169
210
  self.configs = [c for c in configs]
170
211
  assert len(self.configs) > 0, 'configs must not be empty'
171
212
  assert window >= 1, f'window must be at least 1, got {window}'
172
213
  assert eps > 0., f'eps must be positive, got {eps}'
214
+ assert not exists(log_every) or log_every >= 1, f'log_every must be at least 1, got {log_every}'
173
215
 
174
216
  self.target = target
175
217
  self.kappa = kappa
@@ -180,8 +222,13 @@ class SGSSampler:
180
222
  self.default_prob = default_prob
181
223
 
182
224
  self.score_fn = score_fn
225
+ self.sample_fn = sample_fn
183
226
  self.state_transform = state_transform
184
227
 
228
+ self.log_every = log_every
229
+ self.log_path = log_path
230
+ self.trace: list[dict] = [] # progression of the bank, one snapshot per log_every episodes
231
+
185
232
  self.history: list[deque[float]] = [deque(maxlen = self.window) for _ in range(len(self.configs))]
186
233
  self.counts = torch.zeros(len(self.configs), dtype = torch.long)
187
234
  self.step_count = 0
@@ -196,27 +243,42 @@ class SGSSampler:
196
243
  rates = [float(np.mean(h)) if len(h) else float('nan') for h in self.history]
197
244
  return torch.tensor(rates, dtype = torch.float32)
198
245
 
246
+ def _bank_args(self, step: int) -> dict:
247
+ # the common bank state both hooks are handed - the score fn sees this, the sampler
248
+ # all of it
249
+
250
+ return dict(
251
+ p = self.success_rates,
252
+ counts = self.counts,
253
+ history = self.history,
254
+ step = step,
255
+ window = self.window,
256
+ target = resolve_schedule(self.target, step),
257
+ kappa = resolve_schedule(self.kappa, step),
258
+ eps = self.eps,
259
+ unvisited_weight = self.unvisited_weight,
260
+ )
261
+
199
262
  @property
200
263
  def weights(self) -> torch.Tensor:
201
- p = self.success_rates
264
+ # score_fn is swappable - handed, by name, whatever it declares (see `inject_args`),
265
+ # sgs_score_fn by default
202
266
 
203
- if exists(self.score_fn):
204
- return torch.as_tensor(call_hook(self.score_fn, p, self.counts, self.step_count), dtype = torch.float32).clamp(min = 0.)
267
+ score_fn = self.score_fn if exists(self.score_fn) else sgs_score_fn
205
268
 
206
- return sgs_score_fn(
207
- p,
208
- self.counts,
209
- target = resolve_schedule(self.target, self.step_count),
210
- kappa = resolve_schedule(self.kappa, self.step_count),
211
- eps = self.eps,
212
- unvisited_weight = self.unvisited_weight
213
- )
269
+ weights = score_fn(**inject_args(score_fn, self._bank_args(self.step_count)))
270
+
271
+ return torch.as_tensor(weights, dtype = torch.float32).clamp(min = 0.)
214
272
 
215
273
  @property
216
274
  def probs(self) -> torch.Tensor:
217
- w = self.weights
218
- total = w.sum()
219
- probs = w / total if total > 0. else torch.full_like(w, 1. / len(w))
275
+ return self._mixed_probs(self.weights)
276
+
277
+ def _mixed_probs(self, weights: torch.Tensor) -> torch.Tensor:
278
+ # normalize, then mix in the uniform floor - what the default sampler draws from
279
+
280
+ total = weights.sum()
281
+ probs = weights / total if total > 0. else torch.full_like(weights, 1. / len(weights))
220
282
 
221
283
  uniform_prob = resolve_schedule(self.uniform_prob, self.step_count)
222
284
  assert 0. <= uniform_prob <= 1., f'uniform_prob must be within [0, 1], got {uniform_prob}'
@@ -239,15 +301,30 @@ class SGSSampler:
239
301
 
240
302
  def sample(self, batch_size: int | None = None):
241
303
  size = 1 if not exists(batch_size) else int(batch_size)
304
+ step = self.step_count
305
+
306
+ # sample_fn is swappable, handed whatever it declares (see `inject_args`) - the
307
+ # default draws from the beta shaped `probs`, a custom one from any distribution
308
+
309
+ sample_fn = self.sample_fn if exists(self.sample_fn) else sgs_sample_fn
310
+
311
+ weights = self.weights
312
+ available = dict(
313
+ **self._bank_args(step),
314
+ configs = self.configs,
315
+ weights = weights,
316
+ probs = self._mixed_probs(weights),
317
+ rng = self.rng,
318
+ size = size,
319
+ uniform_prob = resolve_schedule(self.uniform_prob, step),
320
+ )
242
321
 
243
- probs_np = self.probs.detach().cpu().numpy()
244
- probs_np = probs_np / probs_np.sum()
322
+ indices = to_numpy(sample_fn(**inject_args(sample_fn, available))).astype(int).reshape(-1)
323
+ assert len(indices) == size, f'sample_fn must return {size} indices, got {len(indices)}'
245
324
 
246
- default_prob = resolve_schedule(self.default_prob, self.step_count)
325
+ default_prob = resolve_schedule(self.default_prob, step)
247
326
  assert 0. <= default_prob <= 1., f'default_prob must be within [0, 1], got {default_prob}'
248
327
 
249
- indices = self.rng.choice(len(self.configs), size = size, p = probs_np)
250
-
251
328
  # a fraction of the draws can fall back to the environment's own reset distribution
252
329
 
253
330
  if default_prob > 0.:
@@ -275,6 +352,31 @@ class SGSSampler:
275
352
  self.counts[idx] += 1
276
353
  self.step_count += 1
277
354
 
355
+ self.log()
356
+
357
+ def log(self):
358
+ # snapshot in memory, and append to the csv when log_path is set
359
+
360
+ if not exists(self.log_every) or not divisible_by(self.step_count, self.log_every):
361
+ return
362
+
363
+ snapshot = dict(step = self.step_count, success_rates = self.success_rates, probs = self.probs)
364
+ self.trace.append(snapshot)
365
+
366
+ if not exists(self.log_path):
367
+ return
368
+
369
+ path = Path(self.log_path)
370
+ path.parent.mkdir(parents = True, exist_ok = True)
371
+
372
+ with path.open('a') as log:
373
+ if log.tell() == 0: # empty file - write the header
374
+ columns = [f'success_rate_{i}' for i in range(len(self))] + [f'prob_{i}' for i in range(len(self))]
375
+ log.write(','.join(['step', *columns]) + '\n')
376
+
377
+ values = [snapshot['step'], *to_numpy(snapshot['success_rates']), *to_numpy(snapshot['probs'])]
378
+ log.write(','.join(f'{value:.4f}' for value in values) + '\n')
379
+
278
380
  def add_config(self, config, init_p: float | None = None) -> int:
279
381
  idx = len(self.configs)
280
382
  self.configs.append(copy_leaf(config))
@@ -289,6 +391,7 @@ class SuccessGuidedSamplingWrapper(EnvWrapper):
289
391
  Success-Guided Sampling (SGS) environment wrapper (Octi Zhang et al., https://arxiv.org/abs/2603.15999).
290
392
  Wraps on top of StandardizeEnvWrapper (or any single / vectorized environment) to adaptively sample
291
393
  initial reset states at the policy's frontier of competence and track per-config success rates.
394
+ Pass `log_every` / `log_path` to record the progression of the bank (see `SGSSampler`).
292
395
  """
293
396
 
294
397
  priority = 110
@@ -305,12 +408,15 @@ class SuccessGuidedSamplingWrapper(EnvWrapper):
305
408
  uniform_prob: float | Callable[[int], float] = 0.,
306
409
  default_prob: float | Callable[[int], float] = 0.,
307
410
  score_fn: Callable | None = None,
411
+ sample_fn: Callable | None = None,
308
412
  state_transform: Callable | None = None,
309
413
  success_fn: Callable | None = None,
310
414
  harvest_fn: Callable | None = None,
311
415
  reset_options_fn: Callable | None = None,
312
416
  set_state_fn: Callable | None = None,
313
417
  options_key: str = 'reset_to_state',
418
+ log_every: int | None = None,
419
+ log_path: str | os.PathLike | None = None,
314
420
  enabled: bool = True,
315
421
  seed: int | None = None,
316
422
  ):
@@ -329,7 +435,10 @@ class SuccessGuidedSamplingWrapper(EnvWrapper):
329
435
  uniform_prob = uniform_prob,
330
436
  default_prob = default_prob,
331
437
  score_fn = score_fn,
438
+ sample_fn = sample_fn,
332
439
  state_transform = state_transform,
440
+ log_every = log_every,
441
+ log_path = log_path,
333
442
  seed = seed,
334
443
  )
335
444
 
@@ -364,6 +473,10 @@ class SuccessGuidedSamplingWrapper(EnvWrapper):
364
473
  def counts(self) -> torch.Tensor:
365
474
  return self.sampler.counts
366
475
 
476
+ @property
477
+ def trace(self) -> list[dict]:
478
+ return self.sampler.trace
479
+
367
480
  def _build_options(self, state, cfg_id) -> dict:
368
481
  if exists(self.reset_options_fn):
369
482
  return call_hook(self.reset_options_fn, state, cfg_id)
@@ -407,13 +520,6 @@ class SuccessGuidedSamplingWrapper(EnvWrapper):
407
520
 
408
521
  return obs, info
409
522
 
410
- self._done_since_reset = np.zeros(self.num_envs, dtype = bool)
411
-
412
- info = dict(info) if isinstance(info, dict) else {}
413
- info['sgs_config_id'] = self._format_config_id_info()
414
-
415
- return obs, info
416
-
417
523
  def step(self, action):
418
524
  obs, reward, terminated, truncated, info = normalize_step_out(self.env.step(action))
419
525
  info = dict(info) if isinstance(info, dict) else {}
@@ -14,6 +14,9 @@ def exists(v):
14
14
  def default(v, d):
15
15
  return v if exists(v) else (d() if callable(d) else d)
16
16
 
17
+ def divisible_by(num, den):
18
+ return (num % den) == 0
19
+
17
20
  def cast_tuple(val, length = 1):
18
21
  if isinstance(val, tuple):
19
22
  return val
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "env-ssl-wrapper"
3
- version = "0.5.6"
3
+ version = "0.5.7"
4
4
  description = "One torch-native interface for any MDP environment"
5
5
  authors = [
6
6
  { name = "Phil Wang", email = "lucidrains@gmail.com" }
File without changes