greenpick 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. greenpick/__init__.py +7 -0
  2. greenpick/__main__.py +18 -0
  3. greenpick/config.py +58 -0
  4. greenpick/data/__init__.py +28 -0
  5. greenpick/data/auto_repository.py +47 -0
  6. greenpick/data/eia_repository.py +156 -0
  7. greenpick/data/electricitymaps_repository.py +104 -0
  8. greenpick/data/emission_factors.json +35 -0
  9. greenpick/data/entsoe_repository.py +401 -0
  10. greenpick/data/gpu_power.json +26 -0
  11. greenpick/data/inference_energy.json +50 -0
  12. greenpick/data/instance_families.json +32 -0
  13. greenpick/data/mock_repository.py +53 -0
  14. greenpick/data/network.json +7 -0
  15. greenpick/data/power.json +7 -0
  16. greenpick/data/pricing.json +31 -0
  17. greenpick/data/regions.json +22 -0
  18. greenpick/data/repository.py +27 -0
  19. greenpick/data/static_data.py +105 -0
  20. greenpick/models.py +342 -0
  21. greenpick/server.py +349 -0
  22. greenpick/services/__init__.py +5 -0
  23. greenpick/services/carbon_service.py +28 -0
  24. greenpick/services/cost_service.py +21 -0
  25. greenpick/services/forecast_service.py +72 -0
  26. greenpick/services/inference_service.py +100 -0
  27. greenpick/services/instance_service.py +132 -0
  28. greenpick/services/latency_service.py +33 -0
  29. greenpick/services/locality_service.py +160 -0
  30. greenpick/services/metric_scaffold_service.py +149 -0
  31. greenpick/services/recommender.py +203 -0
  32. greenpick/services/residency_service.py +53 -0
  33. greenpick/services/rightsizing_service.py +93 -0
  34. greenpick/services/scheduling_service.py +211 -0
  35. greenpick/services/serving_service.py +202 -0
  36. greenpick/services/training_service.py +209 -0
  37. greenpick-0.1.0.dist-info/METADATA +271 -0
  38. greenpick-0.1.0.dist-info/RECORD +42 -0
  39. greenpick-0.1.0.dist-info/WHEEL +5 -0
  40. greenpick-0.1.0.dist-info/entry_points.txt +2 -0
  41. greenpick-0.1.0.dist-info/licenses/LICENSE +21 -0
  42. greenpick-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,202 @@
1
+ """Serving-pattern advice: should I serve my users from ONE region or SEVERAL?
2
+
3
+ A design-time question for a user-facing platform: given where your users are (rough geographic
4
+ shares are fine), is it better to serve everyone from a single region, or run multiple regions
5
+ closer to each hub? GreenPick scores both on traffic-weighted latency and grid carbon, then
6
+ composes the caveats that make multi-region non-free: cross-region DATA-SYNC egress (keeping shared
7
+ state consistent across regions) and cross-JURISDICTION residency (a hub served from another
8
+ jurisdiction).
9
+
10
+ Firmly in GreenPick's lane: design-time, directional, stateless, reuses carbon + latency + egress +
11
+ residency. It is NOT a runtime meter — it never measures a running app; it advises before you build.
12
+ Input (user geography) is soft and often unknown precisely at design time, so rough % shares are
13
+ accepted and everything is labelled directional.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional
18
+
19
+ from ..models import (
20
+ Workload, GeoPoint, Provider, ServingHub, ServingSinglePlan,
21
+ ServingPerHubChoice, ServingPatternAdvice,
22
+ )
23
+ from ..data.repository import CarbonRepository
24
+ from ..data.static_data import iter_regions, region_meta
25
+ from . import carbon_service, latency_service, residency_service
26
+
27
+ # Representative coordinates for common named hubs, so callers can say "eu"/"us"/"apac" without
28
+ # supplying lat/lon. Rough centroids of where users typically are, not of the regions.
29
+ _HUB_COORDS = {
30
+ "us": (39.0, -98.0), "us-east": (38.9, -77.0), "us-west": (37.8, -122.3),
31
+ "eu": (50.1, 8.7), "eu-west": (53.3, -6.3), "eu-north": (59.3, 18.1),
32
+ "uk": (51.5, -0.1), "apac": (1.35, 103.8), "au": (-33.9, 151.2),
33
+ "india": (19.1, 72.9), "japan": (35.7, 139.7), "sa": (-23.5, -46.6),
34
+ }
35
+
36
+ LIMITATIONS = (
37
+ "Directional, design-time guidance — NOT a runtime measurement of your app. User geography is "
38
+ "whatever you supply (rough % shares are fine); results are only as good as that estimate. "
39
+ "Multi-region adds operational complexity, data-sync egress, and possible residency constraints "
40
+ "that GreenPick flags but does not fully cost. Carbon/latency are directional (see "
41
+ "explain_methodology_and_limits)."
42
+ )
43
+
44
+
45
+ def resolve_hub(h: ServingHub) -> GeoPoint:
46
+ """A hub may give explicit lat/lon, or rely on a known name -> representative coords."""
47
+ key = h.name.strip().lower()
48
+ if key in _HUB_COORDS and (h.lat == 0 and h.lon == 0):
49
+ lat, lon = _HUB_COORDS[key]
50
+ return GeoPoint(lat=lat, lon=lon)
51
+ return GeoPoint(lat=h.lat, lon=h.lon)
52
+
53
+
54
+ async def _carbon(repo: CarbonRepository, provider: str, region: str):
55
+ return await carbon_service.carbon_now(repo, provider, region) # type: ignore[arg-type]
56
+
57
+
58
+ async def serving_pattern(
59
+ repo: CarbonRepository,
60
+ hubs: list[ServingHub],
61
+ workload: Workload,
62
+ providers: Optional[list[Provider]] = None,
63
+ ) -> ServingPatternAdvice:
64
+ if not hubs:
65
+ return ServingPatternAdvice(hubs=[], rationale="No user hubs supplied — give at least one "
66
+ "(name + rough share) so I can advise on where to serve from.",
67
+ limitations=LIMITATIONS)
68
+
69
+ # Normalise shares so they sum to 1 (callers often give rough buckets like 40/40/20).
70
+ total_share = sum(h.share for h in hubs) or 1.0
71
+ weights = [h.share / total_share for h in hubs]
72
+ points = [resolve_hub(h) for h in hubs]
73
+
74
+ wanted = set(providers) if providers else {"aws", "gcp", "azure"}
75
+ catalog = [(p, r) for p, r, _m in iter_regions() if p in wanted]
76
+
77
+ # Carbon per candidate region (one fetch each). Latency is pure geometry, no fetch.
78
+ carbon_cache: dict[tuple[str, str], object] = {}
79
+ import asyncio
80
+ readings = await asyncio.gather(*(_carbon(repo, p, r) for p, r in catalog))
81
+ for (p, r), reading in zip(catalog, readings):
82
+ carbon_cache[(p, r)] = reading
83
+
84
+ def latency(p: str, r: str, pt: GeoPoint) -> Optional[float]:
85
+ return latency_service.estimate_latency_ms(p, r, pt) # type: ignore[arg-type]
86
+
87
+ # --- Single-region-for-all: pick the region minimising traffic-weighted latency ---
88
+ best_single = None
89
+ for (p, r) in catalog:
90
+ lats = [latency(p, r, pt) for pt in points]
91
+ if any(l is None for l in lats):
92
+ continue
93
+ wlat = sum(w * l for w, l in zip(weights, lats)) # type: ignore[arg-type]
94
+ if best_single is None or wlat < best_single[0]:
95
+ best_single = (wlat, p, r)
96
+
97
+ single_plan = None
98
+ if best_single is not None:
99
+ wlat, p, r = best_single
100
+ reading = carbon_cache[(p, r)]
101
+ single_plan = ServingSinglePlan(
102
+ provider=p, region=r, weighted_latency_ms=round(wlat, 1), # type: ignore[arg-type]
103
+ carbon_g_co2_per_kwh=reading.g_co2_per_kwh, carbon_source=reading.source, # type: ignore[attr-defined]
104
+ carbon_quality=reading.quality, # type: ignore[attr-defined]
105
+ )
106
+
107
+ # --- Multi-region: for each hub, the region that best balances latency + carbon near it ---
108
+ per_hub: list[ServingPerHubChoice] = []
109
+ for h, pt, w in zip(hubs, points, weights):
110
+ scored = []
111
+ for (p, r) in catalog:
112
+ l = latency(p, r, pt)
113
+ if l is None:
114
+ continue
115
+ reading = carbon_cache[(p, r)]
116
+ # Rank each hub's region by latency first (serving locality is the point of going
117
+ # multi-region), using carbon as the tiebreaker among nearby options (+/-30ms).
118
+ scored.append((l, reading.g_co2_per_kwh, p, r)) # type: ignore[attr-defined]
119
+ if not scored:
120
+ continue
121
+ scored.sort(key=lambda t: t[0])
122
+ near_min = scored[0][0] + 30.0
123
+ nearby = [s for s in scored if s[0] <= near_min]
124
+ nearby.sort(key=lambda t: t[1]) # greenest among the near-latency options
125
+ l, g, p, r = nearby[0]
126
+ per_hub.append(ServingPerHubChoice(
127
+ hub=h.name, provider=p, region=r, latency_ms=round(l, 1),
128
+ carbon_g_co2_per_kwh=g,
129
+ ))
130
+
131
+ multi_wlat = None
132
+ multi_blended = None
133
+ if len(per_hub) == len(hubs):
134
+ multi_wlat = round(sum(w * c.latency_ms for w, c in zip(weights, per_hub)), 1)
135
+ multi_blended = round(sum(w * c.carbon_g_co2_per_kwh for w, c in zip(weights, per_hub)), 1)
136
+
137
+ # --- Caveats that make multi-region non-free ---
138
+ data_sync_note = None
139
+ residency_note = None
140
+ distinct_regions = {(c.provider, c.region) for c in per_hub}
141
+ if len(distinct_regions) > 1:
142
+ data_sync_note = (
143
+ "Multi-region means shared state (DBs, sessions, caches) must sync ACROSS regions — "
144
+ "that cross-region traffic is billed egress and adds its own transfer carbon. Model it "
145
+ "with data_locality_egress_check before committing; it can erase the latency/carbon win "
146
+ "for write-heavy or chatty-shared-state apps.")
147
+ # Residency: does any hub get served from a different jurisdiction than the hub sits in?
148
+ for h, pt, c in zip(hubs, points, per_hub):
149
+ hub_jur = _nearest_jurisdiction(pt)
150
+ serve_jur = residency_service.jurisdiction_of(c.provider, c.region)
151
+ if hub_jur and serve_jur and hub_jur != serve_jur:
152
+ residency_note = (
153
+ f"RESIDENCY: at least one hub ('{h.name}') would be served from a region in a "
154
+ f"different jurisdiction ({serve_jur}) than the users appear to sit in ({hub_jur}). "
155
+ f"If user data is regulated, this may matter. " + residency_service.DISCLAIMER)
156
+ break
157
+
158
+ # --- Decide + explain ---
159
+ recommended, rationale = _decide(single_plan, per_hub, multi_wlat, multi_blended,
160
+ len(distinct_regions))
161
+
162
+ return ServingPatternAdvice(
163
+ hubs=hubs, single_region=single_plan, multi_region=per_hub,
164
+ multi_region_weighted_latency_ms=multi_wlat,
165
+ multi_region_blended_carbon_g_co2_per_kwh=multi_blended,
166
+ recommended_pattern=recommended, data_sync_egress_note=data_sync_note,
167
+ residency_note=residency_note, rationale=rationale, limitations=LIMITATIONS,
168
+ )
169
+
170
+
171
+ def _nearest_jurisdiction(pt: GeoPoint) -> Optional[str]:
172
+ """Infer a hub's jurisdiction from the nearest catalogued region's tag (directional)."""
173
+ best = None
174
+ for p, r, meta in iter_regions():
175
+ if "lat" not in meta or "jurisdiction" not in meta:
176
+ continue
177
+ d = (meta["lat"] - pt.lat) ** 2 + (meta["lon"] - pt.lon) ** 2
178
+ if best is None or d < best[0]:
179
+ best = (d, meta["jurisdiction"])
180
+ return best[1] if best else None
181
+
182
+
183
+ def _decide(single, per_hub, multi_wlat, multi_blended, n_distinct):
184
+ """Recommend single vs multi. Single wins unless multi-region materially cuts weighted latency
185
+ (users are genuinely spread), since multi-region's complexity/egress is a real cost."""
186
+ if single is None or not per_hub or multi_wlat is None:
187
+ if single is not None:
188
+ return "single_region", (f"Serve all users from {single.provider}/{single.region} "
189
+ f"(~{single.weighted_latency_ms:.0f}ms weighted latency).")
190
+ return "single_region", "Not enough data to compare serving patterns."
191
+
192
+ lat_saving = single.weighted_latency_ms - multi_wlat
193
+ if n_distinct > 1 and lat_saving >= 40:
194
+ return "multi_region", (
195
+ f"Your users are spread enough that multi-region helps: serving each hub locally cuts "
196
+ f"weighted latency from ~{single.weighted_latency_ms:.0f}ms (single region) to "
197
+ f"~{multi_wlat:.0f}ms, and blends to ~{multi_blended:.0f} gCO2/kWh. Weigh that against "
198
+ f"the data-sync egress and complexity below before committing.")
199
+ return "single_region", (
200
+ f"A single region ({single.provider}/{single.region}, ~{single.weighted_latency_ms:.0f}ms "
201
+ f"weighted) is the better call: multi-region would only save ~{max(0, lat_saving):.0f}ms, "
202
+ f"which rarely justifies the added data-sync egress and operational complexity.")
@@ -0,0 +1,209 @@
1
+ """GPU training carbon: how much CO2 a training run emits, and the greener WHERE + WHEN.
2
+
3
+ Training is the workload where GreenPick's two best levers compound: a run is both PORTABLE
4
+ (pick the region) and DEFERRABLE (pick the hours). Unlike the always-on web service, a run is a
5
+ FIXED batch of work (~N GPU-hours, once), so we can place it in space and time.
6
+
7
+ Energy comes from the directional GPU power table (TDP x utilization + host overhead, x PUE);
8
+ carbon = energy x the region's LIVE grid intensity. Placement ranks regions greener-first; timing
9
+ reuses the carbon-aware scheduling service for the chosen (or greenest) region.
10
+
11
+ Honesty posture: energy is directional (TDP is peak, not a measurement of your run); region GPU
12
+ availability is NOT modelled — we flag it as something to verify, rather than maintain a matrix
13
+ that rots. Spot/on-demand, checkpointing, and multi-node networking are out of scope (orchestration,
14
+ not placement/timing).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+ from ..models import (
21
+ Provider, TrainingCarbonAdvice, TrainingRegionOption, HourlyCarbon,
22
+ )
23
+ from ..data.repository import CarbonRepository
24
+ from ..data.static_data import gpu_power, resolve_gpu, power_model, iter_regions
25
+ from . import carbon_service, scheduling_service, residency_service
26
+
27
+
28
+ def run_energy_kwh(gpu_key: str, gpu_count: int, gpu_hours: float,
29
+ utilization: float) -> float:
30
+ """Directional energy for a run. gpu_hours is TOTAL accelerator-hours across all GPUs
31
+ (e.g. 2 GPUs for 20h = 40 GPU-hours). Energy is independent of how it's split, since
32
+ both the GPU draw and host overhead are per-accelerator."""
33
+ gp = gpu_power()
34
+ _key, spec = resolve_gpu(gpu_key) # caller guarantees resolvable
35
+ tdp = spec["tdp_watts"]
36
+ host = gp["host_overhead_watts_per_gpu"]
37
+ pue = power_model()["pue_default"]
38
+ watts_per_gpu = tdp * utilization + host
39
+ # gpu_hours is total accelerator-hours, so multiply watts-per-gpu by total gpu-hours directly.
40
+ return watts_per_gpu / 1000.0 * gpu_hours * pue
41
+
42
+
43
+ def _rate(options: list[TrainingRegionOption]) -> None:
44
+ """Relative R/Y/G by carbon terciles across the option set (greenest = green)."""
45
+ if not options:
46
+ return
47
+ ordered = sorted(options, key=lambda o: o.est_kg_co2)
48
+ n = len(ordered)
49
+ for i, opt in enumerate(ordered):
50
+ frac = i / n
51
+ opt.rating = "green" if frac < 1/3 else "yellow" if frac < 2/3 else "red"
52
+
53
+
54
+ LIMITATIONS = (
55
+ "Energy is DIRECTIONAL: published GPU TDP (peak, not your run's measured draw) x a utilization "
56
+ "assumption + host overhead x PUE. Carbon = energy x the region's grid intensity. GPU AVAILABILITY "
57
+ "per region is NOT checked — verify the card you need is offered there. Ignores spot/on-demand, "
58
+ "checkpointing, multi-node networking, and embodied hardware carbon. Training datasets are often "
59
+ "large and may live in another region: shipping them to the greenest region can incur significant "
60
+ "EGRESS cost and transfer carbon — run data_locality_egress_check before moving the job."
61
+ )
62
+
63
+
64
+ async def estimate_training_carbon(
65
+ repo: CarbonRepository,
66
+ gpu: str,
67
+ gpu_count: int,
68
+ gpu_hours: float,
69
+ region: Optional[str] = None,
70
+ provider: Optional[Provider] = None,
71
+ utilization: Optional[float] = None,
72
+ top_n_regions: int = 5,
73
+ ) -> TrainingCarbonAdvice:
74
+ gp = gpu_power()
75
+ resolved = resolve_gpu(gpu)
76
+ util = utilization if utilization is not None else gp["default_utilization"]
77
+
78
+ if resolved is None:
79
+ known = ", ".join(gp["gpus"].keys())
80
+ return TrainingCarbonAdvice(
81
+ gpu=gpu, gpu_name=gpu, gpu_count=gpu_count, gpu_hours=gpu_hours,
82
+ utilization=util, est_energy_kwh=0.0,
83
+ rationale=f"Unknown GPU '{gpu}'. Known: {known}.",
84
+ limitations=LIMITATIONS,
85
+ )
86
+
87
+ gpu_key, spec = resolved
88
+ energy = round(run_energy_kwh(gpu_key, gpu_count, gpu_hours, util), 2)
89
+
90
+ base = TrainingCarbonAdvice(
91
+ gpu=gpu_key, gpu_name=spec["name"], gpu_count=gpu_count, gpu_hours=gpu_hours,
92
+ utilization=util, est_energy_kwh=energy, limitations=LIMITATIONS,
93
+ )
94
+
95
+ # --- Placement: a specific region, or rank the catalog greener-first ---
96
+ if region is not None:
97
+ prov = provider or _guess_provider(region)
98
+ if prov is None:
99
+ base.rationale = (f"Region '{region}' not found. Pass provider= explicitly, or omit "
100
+ f"region to rank all regions.")
101
+ return base
102
+ reading = await carbon_service.carbon_now(repo, prov, region)
103
+ kg = round(energy * reading.g_co2_per_kwh / 1000.0, 2)
104
+ base.region = region
105
+ base.recommended_region = f"{prov}/{region}"
106
+ base.options = [TrainingRegionOption(
107
+ provider=prov, region=region, grid_intensity_g_co2_per_kwh=reading.g_co2_per_kwh,
108
+ carbon_source=reading.source, carbon_quality=reading.quality, est_kg_co2=kg,
109
+ rating="green",
110
+ )]
111
+ chosen_prov, chosen_region = prov, region
112
+ place_txt = (f"This run is ~{energy:.0f} kWh. In {prov}/{region} that's ~{kg:.1f} kg CO2 "
113
+ f"at {reading.g_co2_per_kwh:.0f} gCO2/kWh ({reading.source}, {reading.quality}).")
114
+ else:
115
+ wanted = {provider} if provider else {"aws", "gcp", "azure"}
116
+ targets = [(prov, reg) for prov, reg, _meta in iter_regions() if prov in wanted]
117
+ # Fetch all regions' carbon CONCURRENTLY — live sources (esp. ENTSO-E with retries) are
118
+ # slow serially, and ranking needs every region before it can sort.
119
+ import asyncio
120
+ readings = await asyncio.gather(
121
+ *(carbon_service.carbon_now(repo, prov, reg) for prov, reg in targets) # type: ignore[arg-type]
122
+ )
123
+ options: list[TrainingRegionOption] = []
124
+ for (prov, reg), reading in zip(targets, readings):
125
+ kg = round(energy * reading.g_co2_per_kwh / 1000.0, 2)
126
+ options.append(TrainingRegionOption(
127
+ provider=prov, region=reg, # type: ignore[arg-type]
128
+ grid_intensity_g_co2_per_kwh=reading.g_co2_per_kwh,
129
+ carbon_source=reading.source, carbon_quality=reading.quality, est_kg_co2=kg,
130
+ ))
131
+ if not options:
132
+ base.rationale = "No known regions matched the request."
133
+ return base
134
+ options.sort(key=lambda o: o.est_kg_co2) # greenest first
135
+ greenest = options[0]
136
+ dirtiest = options[-1]
137
+ # vs_baseline computed against the FULL set's dirtiest (the real worst case), so the
138
+ # headline "X% less than the dirtiest" reflects every region, not just the shown slice.
139
+ for o in options:
140
+ if dirtiest.est_kg_co2 > 0:
141
+ o.vs_baseline_pct = round((o.est_kg_co2 - dirtiest.est_kg_co2) / dirtiest.est_kg_co2 * 100, 1)
142
+ shown = options[:max(1, top_n_regions)]
143
+ _rate(shown) # R/Y/G relative to what's actually displayed
144
+ base.options = shown
145
+ base.recommended_region = f"{greenest.provider}/{greenest.region}"
146
+ chosen_prov, chosen_region = greenest.provider, greenest.region
147
+ saved_pct = abs(greenest.vs_baseline_pct) if greenest.vs_baseline_pct else 0.0
148
+ place_txt = (f"This run is ~{energy:.0f} kWh. Greenest option is {greenest.provider}/"
149
+ f"{greenest.region} at ~{greenest.est_kg_co2:.1f} kg CO2 - ~{saved_pct:.0f}% "
150
+ f"less than the dirtiest option ({dirtiest.provider}/{dirtiest.region}, "
151
+ f"~{dirtiest.est_kg_co2:.1f} kg).")
152
+
153
+ # --- Timing: reuse carbon-aware scheduling for the chosen/greenest region ---
154
+ # A run's wall-clock duration ~= total GPU-hours / GPUs running in parallel. If that's >= 2h,
155
+ # ask for the cleanest CONTIGUOUS window of that length (you start one job once), not scattered
156
+ # hours. Cap at 24 so it stays within the history window.
157
+ wall_clock_h = int(round(gpu_hours / max(1, gpu_count)))
158
+ window = wall_clock_h if 2 <= wall_clock_h <= 24 else None
159
+ advice = await scheduling_service.cleanest_hours(
160
+ repo, chosen_prov, chosen_region, top_n=3, window_hours=window,
161
+ )
162
+ base.cleanest_hours = advice.cleanest
163
+ base.cleanest_window = advice.window
164
+ base.timing_reduction_pct = advice.potential_reduction_pct
165
+ base.timing_source = advice.source
166
+ base.timing_quality = advice.quality
167
+
168
+ if advice.window and advice.window_reduction_pct and advice.window_reduction_pct >= 10:
169
+ w = advice.window
170
+ time_txt = (f" It runs ~{wall_clock_h}h wall-clock; the cleanest {w.window_hours}h window to "
171
+ f"start is {w.start_period} (avg ~{w.avg_g_co2_per_kwh:.0f} gCO2/kWh), "
172
+ f"~{advice.window_reduction_pct:.0f}% cleaner than the worst same-length window - "
173
+ f"training is deferrable, so this stacks on the region choice.")
174
+ elif advice.potential_reduction_pct and advice.potential_reduction_pct >= 10 and advice.cleanest:
175
+ best = advice.cleanest[0].period
176
+ time_txt = (f" Timing it to the cleanest hours in that region (around {best}) cuts a further "
177
+ f"~{advice.potential_reduction_pct:.0f}% vs the dirtiest hour - training is "
178
+ f"deferrable, so this stacks on the region choice.")
179
+ else:
180
+ time_txt = (" That region's grid is fairly flat over the window, so when you run matters "
181
+ "little there — the region choice is the lever.")
182
+
183
+ base.rationale = place_txt + time_txt
184
+
185
+ # Residency context: training usually means shipping a dataset INTO the recommended region.
186
+ # We don't know where the data lives, so we state the recommended region's jurisdiction as a
187
+ # neutral heads-up (not a verdict) — most pointed when it's inside the EEA.
188
+ jur = residency_service.jurisdiction_of(chosen_prov, chosen_region)
189
+ if jur:
190
+ where = residency_service._LABEL.get(jur, jur)
191
+ if jur == "EEA":
192
+ base.residency_note = (
193
+ f"RESIDENCY: the recommended region ({chosen_prov}/{chosen_region}) is in {where}. "
194
+ f"Shipping a training dataset in or out of the EEA can trigger GDPR transfer rules "
195
+ f"if it contains personal data. {residency_service.DISCLAIMER}")
196
+ else:
197
+ base.residency_note = (
198
+ f"RESIDENCY: the recommended region ({chosen_prov}/{chosen_region}) is in {where}. "
199
+ f"If your training data lives in another jurisdiction, moving it here may trigger "
200
+ f"residency rules. {residency_service.DISCLAIMER}")
201
+ return base
202
+
203
+
204
+ def _guess_provider(region: str) -> Optional[str]:
205
+ """Find which provider a region code belongs to (region codes are unique across our catalog)."""
206
+ for prov, reg, _meta in iter_regions():
207
+ if reg == region:
208
+ return prov
209
+ return None