constraintml 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.
@@ -0,0 +1,5 @@
1
+ from .trainers import ConstraintTrainer, GreenTrainer, SmartTrainer, TrainingReport
2
+
3
+ __all__ = ["ConstraintTrainer", "GreenTrainer", "SmartTrainer", "TrainingReport"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,29 @@
1
+ """Extension point for optional, provider-specific cloud adapters (AWS/Azure/GCP).
2
+
3
+ For v2+ - not implemented in v1 - ConstraintML is local-first and must work fully without
4
+ any cloud adapter. This module only documents the shape a future adapter would take
5
+ so that `CarbonModel.source` and cost accounting have a defined place to plug into.
6
+ No network or authentication code lives here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol, runtime_checkable
12
+
13
+
14
+ @runtime_checkable
15
+ class CloudAdapter(Protocol):
16
+ """Shape a future cloud adapter would implement. Not used by v1 -- the
17
+ RuntimeOptimizationEngine never depends on this Protocol being satisfied.
18
+ """
19
+
20
+ def get_carbon_intensity(self, region: str) -> float:
21
+ """Return regional grid carbon intensity in kg CO2e/kWh."""
22
+ ...
23
+
24
+ def get_energy_price(self, region: str) -> float:
25
+ """Return regional electricity price in USD/kWh."""
26
+ ...
27
+
28
+
29
+ __all__ = ["CloudAdapter"]
@@ -0,0 +1,3 @@
1
+ from .spec import ConstraintSpec, OPTIMIZE_FOR_CHOICES
2
+
3
+ __all__ = ["ConstraintSpec", "OPTIMIZE_FOR_CHOICES"]
@@ -0,0 +1,98 @@
1
+ """Parsing helpers that turn user-facing constraint strings/numbers into typed values.
2
+
3
+ Every parser accepts either a bare number (assumed to already be in the target unit)
4
+ or a string like "8h", "<50kWh", "25 kg CO2e". A leading comparator ("<", "<=") is
5
+ informational only -- v1 treats every parsed budget as an upper bound regardless of
6
+ the comparator given. A leading ">"/">=" logs a warning since it is nonsensical for
7
+ a resource budget, but the numeric value is still used as an upper bound.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+ from numbers import Number
15
+
16
+ from .units import CARBON_UNITS_TO_KG, DURATION_UNITS_TO_SECONDS, ENERGY_UNITS_TO_KWH, normalize_unit
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _VALUE_PATTERN = re.compile(r"^\s*([<>]=?)?\s*([\d.]+)\s*(.*?)\s*$")
21
+
22
+
23
+ def _split_value_unit(value: str | Number) -> tuple[float, str]:
24
+ if isinstance(value, Number):
25
+ return float(value), ""
26
+
27
+ match = _VALUE_PATTERN.match(str(value))
28
+ if not match:
29
+ raise ValueError(f"Could not parse constraint value: {value!r}")
30
+
31
+ comparator, number, unit = match.groups()
32
+ if comparator in (">", ">="):
33
+ logger.warning(
34
+ "Constraint value %r uses comparator %r, which is nonsensical for an upper-bound "
35
+ "budget; the numeric value will still be treated as an upper bound.",
36
+ value,
37
+ comparator,
38
+ )
39
+
40
+ number = float(number)
41
+ if number < 0:
42
+ raise ValueError(f"Constraint value must be non-negative, got: {value!r}")
43
+
44
+ return number, unit
45
+
46
+
47
+ def parse_duration(value: str | Number) -> float:
48
+ """Parse a duration like "8h", "30m", "1d", or a bare number of seconds -> seconds."""
49
+ number, unit = _split_value_unit(value)
50
+ unit = normalize_unit(unit)
51
+ if unit == "":
52
+ return number
53
+ if unit not in DURATION_UNITS_TO_SECONDS:
54
+ raise ValueError(f"Unknown duration unit {unit!r} in {value!r}")
55
+ return number * DURATION_UNITS_TO_SECONDS[unit]
56
+
57
+
58
+ def parse_energy(value: str | Number) -> float:
59
+ """Parse an energy budget like "100kWh", "<50kWh", or a bare number -> kWh."""
60
+ number, unit = _split_value_unit(value)
61
+ unit = normalize_unit(unit)
62
+ if unit not in ENERGY_UNITS_TO_KWH:
63
+ raise ValueError(f"Unknown energy unit {unit!r} in {value!r}")
64
+ return number * ENERGY_UNITS_TO_KWH[unit]
65
+
66
+
67
+ def parse_carbon(value: str | Number) -> float:
68
+ """Parse a carbon budget like "25 kg CO2e", "8kgCO2e", or a bare number -> kg CO2e."""
69
+ number, unit = _split_value_unit(value)
70
+ unit = normalize_unit(unit)
71
+ if unit not in CARBON_UNITS_TO_KG:
72
+ raise ValueError(f"Unknown carbon unit {unit!r} in {value!r}")
73
+ return number * CARBON_UNITS_TO_KG[unit]
74
+
75
+
76
+ def parse_cost(value: str | Number) -> float:
77
+ """Parse a cost budget like "$50", "50 USD", or a bare number -> USD."""
78
+ if isinstance(value, Number):
79
+ number = float(value)
80
+ else:
81
+ cleaned = str(value).strip().lstrip("$").replace(",", "")
82
+ cleaned = re.sub(r"(?i)\s*usd\s*$", "", cleaned)
83
+ number, _unit = _split_value_unit(cleaned)
84
+ if number < 0:
85
+ raise ValueError(f"Cost budget must be non-negative, got: {value!r}")
86
+ return number
87
+
88
+
89
+ def parse_percentage(value: str | Number) -> float:
90
+ """Parse a percentage like "0.5%" -> 0.005. A bare number < 1 is assumed to already be
91
+ a fraction; a bare number >= 1 is assumed to be a percentage (e.g. 5 -> 0.05)."""
92
+ number, unit = _split_value_unit(value)
93
+ unit = normalize_unit(unit)
94
+ if unit == "%":
95
+ return number / 100
96
+ if unit != "":
97
+ raise ValueError(f"Unknown percentage unit {unit!r} in {value!r}")
98
+ return number if number < 1 else number / 100
@@ -0,0 +1,81 @@
1
+ """ConstraintSpec: the normalized, internal representation of user-supplied budgets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from numbers import Number
7
+
8
+ from .parsing import parse_carbon, parse_cost, parse_duration, parse_energy, parse_percentage
9
+
10
+ OPTIMIZE_FOR_CHOICES = ("accuracy", "energy", "carbon", "cost", "time")
11
+
12
+ _DICT_KEY_ALIASES = {
13
+ "energy": "energy_budget",
14
+ "energy_budget": "energy_budget",
15
+ "carbon": "carbon_budget",
16
+ "carbon_budget": "carbon_budget",
17
+ "deadline": "deadline",
18
+ "max_accuracy_loss": "max_accuracy_loss",
19
+ "accuracy_loss": "max_accuracy_loss",
20
+ "cost": "cost_budget",
21
+ "cost_budget": "cost_budget",
22
+ }
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ConstraintSpec:
27
+ energy_budget_kwh: float | None = None
28
+ carbon_budget_kgco2e: float | None = None
29
+ deadline_seconds: float | None = None
30
+ max_accuracy_loss: float | None = None
31
+ cost_budget_usd: float | None = None
32
+ optimize_for: str = "accuracy"
33
+
34
+ def __post_init__(self):
35
+ if self.optimize_for not in OPTIMIZE_FOR_CHOICES:
36
+ raise ValueError(
37
+ f"optimize_for must be one of {OPTIMIZE_FOR_CHOICES}, got {self.optimize_for!r}"
38
+ )
39
+
40
+ @classmethod
41
+ def from_kwargs(
42
+ cls,
43
+ *,
44
+ energy_budget: str | Number | None = None,
45
+ carbon_budget: str | Number | None = None,
46
+ deadline: str | Number | None = None,
47
+ max_accuracy_loss: str | Number | None = None,
48
+ cost_budget: str | Number | None = None,
49
+ optimize_for: str = "accuracy",
50
+ ) -> "ConstraintSpec":
51
+ return cls(
52
+ energy_budget_kwh=parse_energy(energy_budget) if energy_budget is not None else None,
53
+ carbon_budget_kgco2e=parse_carbon(carbon_budget) if carbon_budget is not None else None,
54
+ deadline_seconds=parse_duration(deadline) if deadline is not None else None,
55
+ max_accuracy_loss=parse_percentage(max_accuracy_loss) if max_accuracy_loss is not None else None,
56
+ cost_budget_usd=parse_cost(cost_budget) if cost_budget is not None else None,
57
+ optimize_for=optimize_for,
58
+ )
59
+
60
+ @classmethod
61
+ def from_dict(cls, constraints: dict, optimize_for: str = "accuracy") -> "ConstraintSpec":
62
+ kwargs: dict = {}
63
+ for key, value in constraints.items():
64
+ try:
65
+ field = _DICT_KEY_ALIASES[key]
66
+ except KeyError:
67
+ raise ValueError(f"Unknown constraint key: {key!r}") from None
68
+ kwargs[field] = value
69
+ return cls.from_kwargs(optimize_for=optimize_for, **kwargs)
70
+
71
+ def merge(self, other: "ConstraintSpec") -> "ConstraintSpec":
72
+ """Return a spec with `other`'s non-None fields overriding this spec's fields.
73
+
74
+ Used to let named kwargs (e.g. GreenTrainer(energy_budget=...)) win over a
75
+ `constraints={...}` dict passed to the same constructor.
76
+ """
77
+ merged = {}
78
+ for field_name in self.__dataclass_fields__:
79
+ other_value = getattr(other, field_name)
80
+ merged[field_name] = other_value if other_value is not None else getattr(self, field_name)
81
+ return ConstraintSpec(**merged)
@@ -0,0 +1,54 @@
1
+ """Unit lookup tables used by constraints.parsing."""
2
+
3
+ DURATION_UNITS_TO_SECONDS = {
4
+ "s": 1,
5
+ "sec": 1,
6
+ "secs": 1,
7
+ "second": 1,
8
+ "seconds": 1,
9
+ "m": 60,
10
+ "min": 60,
11
+ "mins": 60,
12
+ "minute": 60,
13
+ "minutes": 60,
14
+ "h": 3600,
15
+ "hr": 3600,
16
+ "hrs": 3600,
17
+ "hour": 3600,
18
+ "hours": 3600,
19
+ "d": 86400,
20
+ "day": 86400,
21
+ "days": 86400,
22
+ }
23
+
24
+ # All keys normalized (lowercase, spaces/dashes/underscores stripped) to kWh.
25
+ ENERGY_UNITS_TO_KWH = {
26
+ "kwh": 1.0,
27
+ "wh": 1e-3,
28
+ "mwh": 1e3,
29
+ "j": 1 / 3_600_000,
30
+ "joule": 1 / 3_600_000,
31
+ "joules": 1 / 3_600_000,
32
+ "kj": 1e3 / 3_600_000,
33
+ "mj": 1e6 / 3_600_000,
34
+ "": 1.0, # bare number defaults to kWh
35
+ }
36
+
37
+ # All keys normalized (lowercase, spaces/dashes/underscores stripped) to kg CO2e.
38
+ CARBON_UNITS_TO_KG = {
39
+ "kgco2e": 1.0,
40
+ "kgco2": 1.0,
41
+ "kg": 1.0,
42
+ "gco2e": 1e-3,
43
+ "gco2": 1e-3,
44
+ "g": 1e-3,
45
+ "tco2e": 1e3,
46
+ "tco2": 1e3,
47
+ "t": 1e3,
48
+ "": 1.0, # bare number defaults to kg CO2e
49
+ }
50
+
51
+
52
+ def normalize_unit(unit: str) -> str:
53
+ """Lowercase and strip spaces/dashes/underscores so 'kg CO2e' == 'kgCO2e' == 'kg-co2e'."""
54
+ return unit.strip().lower().replace(" ", "").replace("-", "").replace("_", "")
@@ -0,0 +1,17 @@
1
+ from .carbon import CarbonModel, DEFAULT_GRID_CARBON_INTENSITY_KG_PER_KWH
2
+ from .engine import RuntimeOptimizationEngine
3
+ from .planner import ConstraintPlanner
4
+ from .state import EpochSnapshot, RunState
5
+ from .strategies import ExecutionStrategy, PlannerDecision, StrategyAction
6
+
7
+ __all__ = [
8
+ "CarbonModel",
9
+ "DEFAULT_GRID_CARBON_INTENSITY_KG_PER_KWH",
10
+ "RuntimeOptimizationEngine",
11
+ "ConstraintPlanner",
12
+ "RunState",
13
+ "EpochSnapshot",
14
+ "ExecutionStrategy",
15
+ "PlannerDecision",
16
+ "StrategyAction",
17
+ ]
@@ -0,0 +1,4 @@
1
+ from .base import ExecutionBackend
2
+ from .pytorch_backend import PyTorchBackend
3
+
4
+ __all__ = ["ExecutionBackend", "PyTorchBackend"]
@@ -0,0 +1,29 @@
1
+ """Framework-agnostic execution backend interface.
2
+
3
+ v1 ships only `PyTorchBackend`. JAX/TensorFlow backends would implement this same
4
+ protocol; none exist yet.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from typing import Any
11
+
12
+ from ..strategies import ExecutionStrategy
13
+
14
+
15
+ class ExecutionBackend(ABC):
16
+ @abstractmethod
17
+ def training_step(self, batch: Any, loss_fn: Any) -> float:
18
+ """Run one training step (forward + backward + optimizer update as appropriate
19
+ given accumulation state) and return the scalar loss value."""
20
+
21
+ @abstractmethod
22
+ def apply_strategy(self, strategy: ExecutionStrategy) -> None:
23
+ """Reconfigure execution (precision, batch size, grad accumulation,
24
+ checkpointing) to match the given strategy."""
25
+
26
+ @abstractmethod
27
+ def evaluate(self, val_loader: Any) -> float | None:
28
+ """Run validation and return an accuracy-like metric (higher is better),
29
+ or None if it cannot be computed."""
@@ -0,0 +1,116 @@
1
+ """PyTorch execution backend: the only real ExecutionBackend implementation for v1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ import torch
8
+ from torch.utils.data import DataLoader, RandomSampler
9
+
10
+ from ..strategies import ExecutionStrategy
11
+ from .base import ExecutionBackend
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class PyTorchBackend(ExecutionBackend):
17
+ def __init__(self, model, optimizer, train_loader, loss_fn, device=None):
18
+ self.model = model
19
+ self.optimizer = optimizer
20
+ self.train_loader = train_loader
21
+ self.loss_fn = loss_fn
22
+ self.device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
23
+ self.model.to(self.device)
24
+
25
+ initial_batch_size = getattr(train_loader, "batch_size", None) or 0
26
+ self.strategy = ExecutionStrategy(batch_size=initial_batch_size)
27
+ self._initial_batch_size = initial_batch_size
28
+ self._micro_step = 0
29
+ self._checkpoint_warned = False
30
+
31
+ self.optimizer.zero_grad()
32
+
33
+ def apply_strategy(self, strategy: ExecutionStrategy) -> None:
34
+ if strategy.batch_size and strategy.batch_size != self.strategy.batch_size:
35
+ self._resize_batch(strategy.batch_size)
36
+ self.strategy = strategy
37
+
38
+ def _resize_batch(self, batch_size: int) -> None:
39
+ dataset = getattr(self.train_loader, "dataset", None)
40
+ if dataset is None:
41
+ logger.warning(
42
+ "Cannot resize batch size to %d: train_loader has no `.dataset` "
43
+ "(only map-style DataLoaders support runtime batch-size changes in v1).",
44
+ batch_size,
45
+ )
46
+ return
47
+ shuffled = isinstance(getattr(self.train_loader, "sampler", None), RandomSampler)
48
+ self.train_loader = DataLoader(
49
+ dataset,
50
+ batch_size=batch_size,
51
+ shuffle=shuffled,
52
+ num_workers=getattr(self.train_loader, "num_workers", 0),
53
+ )
54
+
55
+ def training_step(self, batch, loss_fn=None) -> float:
56
+ loss_fn = loss_fn or self.loss_fn
57
+ inputs, targets = batch
58
+ inputs = inputs.to(self.device)
59
+ targets = targets.to(self.device)
60
+
61
+ autocast_enabled = self.strategy.precision == "fp16"
62
+ # CPU has no native fp16 compute kernels -- torch.autocast(dtype=float16) on CPU
63
+ # falls back to emulation and measured ~44x SLOWER than fp32 for a mid-sized MLP
64
+ # on this machine, actively defeating the time/energy budget instead of helping it.
65
+ # bfloat16 is the dtype CPU autocast is actually designed around; fp16 remains
66
+ # correct for CUDA, where it's backed by real tensor-core throughput.
67
+ autocast_dtype = torch.bfloat16 if self.device.type == "cpu" else torch.float16
68
+ with torch.autocast(device_type=self.device.type, enabled=autocast_enabled, dtype=autocast_dtype):
69
+ outputs = self._forward(inputs)
70
+ loss = loss_fn(outputs, targets)
71
+
72
+ (loss / self.strategy.grad_accumulation_steps).backward()
73
+ self._micro_step += 1
74
+
75
+ if self._micro_step % self.strategy.grad_accumulation_steps == 0:
76
+ self.optimizer.step()
77
+ self.optimizer.zero_grad()
78
+
79
+ return loss.item()
80
+
81
+ def _forward(self, inputs):
82
+ if self.strategy.activation_checkpointing:
83
+ if isinstance(self.model, torch.nn.Sequential) and len(self.model) > 1:
84
+ return torch.utils.checkpoint.checkpoint_sequential(
85
+ self.model, len(self.model), inputs, use_reentrant=False
86
+ )
87
+ if not self._checkpoint_warned:
88
+ logger.warning(
89
+ "Activation checkpointing requested but the model is not an "
90
+ "nn.Sequential, so its forward pass cannot be automatically "
91
+ "wrapped in v1. Continuing without checkpointing."
92
+ )
93
+ self._checkpoint_warned = True
94
+ return self.model(inputs)
95
+
96
+ def evaluate(self, val_loader) -> float | None:
97
+ if val_loader is None or self.loss_fn is None:
98
+ return None
99
+
100
+ self.model.eval()
101
+ total_loss = 0.0
102
+ total_batches = 0
103
+ with torch.no_grad():
104
+ for inputs, targets in val_loader:
105
+ inputs = inputs.to(self.device)
106
+ targets = targets.to(self.device)
107
+ outputs = self.model(inputs)
108
+ total_loss += self.loss_fn(outputs, targets).item()
109
+ total_batches += 1
110
+ self.model.train()
111
+
112
+ if total_batches == 0:
113
+ return None
114
+ # "Accuracy-like" metric where higher is better; without a task-specific
115
+ # metric available, negative average validation loss becomes the generic proxy.
116
+ return -(total_loss / total_batches)
@@ -0,0 +1,20 @@
1
+ """Carbon accounting: converts energy consumption into estimated carbon emissions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ # Rough global-average grid carbon intensity. This is a coarse approximation --
8
+ # override with a locally accurate figure via `carbon_intensity_kg_per_kwh=` on any
9
+ # trainer. A future `constraintml.cloud` adapter is the intended extension point for
10
+ # sourcing a regionally accurate figure automatically (see CarbonModel.source).
11
+ DEFAULT_GRID_CARBON_INTENSITY_KG_PER_KWH = 0.4
12
+
13
+
14
+ @dataclass
15
+ class CarbonModel:
16
+ intensity_kg_per_kwh: float = DEFAULT_GRID_CARBON_INTENSITY_KG_PER_KWH
17
+ source: str = "static_default"
18
+
19
+ def to_carbon(self, energy_kwh: float) -> float:
20
+ return energy_kwh * self.intensity_kg_per_kwh
@@ -0,0 +1,76 @@
1
+ """RuntimeOptimizationEngine: orchestrates telemetry, carbon accounting, and the
2
+ planner around each training step, applying any resulting strategy to the backend.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+
9
+ from .backends.base import ExecutionBackend
10
+ from .carbon import CarbonModel
11
+ from .planner import ConstraintPlanner
12
+ from .state import RunState
13
+ from .strategies import StrategyAction
14
+ from .telemetry.base import TelemetryCollector, TelemetrySample
15
+
16
+
17
+ class RuntimeOptimizationEngine:
18
+ def __init__(
19
+ self,
20
+ planner: ConstraintPlanner,
21
+ telemetry: TelemetryCollector,
22
+ backend: ExecutionBackend,
23
+ carbon_model: CarbonModel | None = None,
24
+ cost_per_kwh_usd: float | None = None,
25
+ state: RunState | None = None,
26
+ ):
27
+ self.planner = planner
28
+ self.telemetry = telemetry
29
+ self.backend = backend
30
+ self.carbon_model = carbon_model or CarbonModel()
31
+ self.cost_per_kwh_usd = cost_per_kwh_usd
32
+ self.state = state or RunState()
33
+
34
+ self._step_start_time: float | None = None
35
+ self._step_start_sample: TelemetrySample | None = None
36
+
37
+ def begin_step(self) -> None:
38
+ self._step_start_time = time.perf_counter()
39
+ self._step_start_sample = self.telemetry.sample()
40
+
41
+ def end_step(self, loss: float | None = None) -> None:
42
+ if self._step_start_time is None or self._step_start_sample is None:
43
+ raise RuntimeError("end_step() called without a matching begin_step().")
44
+
45
+ end_sample = self.telemetry.sample()
46
+ elapsed = time.perf_counter() - self._step_start_time
47
+
48
+ energy_kwh = self.telemetry.integrate_energy(self._step_start_sample, end_sample, elapsed)
49
+ carbon_kgco2e = self.carbon_model.to_carbon(energy_kwh)
50
+
51
+ self.state.cumulative_energy_kwh += energy_kwh
52
+ self.state.cumulative_carbon_kgco2e += carbon_kgco2e
53
+ if self.cost_per_kwh_usd is not None:
54
+ self.state.cumulative_cost_usd += energy_kwh * self.cost_per_kwh_usd
55
+ self.state.elapsed_seconds += elapsed
56
+ self.state.step_count += 1
57
+
58
+ self._step_start_time = None
59
+ self._step_start_sample = None
60
+
61
+ if self.planner.should_evaluate(self.state):
62
+ decision = self.planner.decide(self.state)
63
+ self._apply_decision(decision)
64
+
65
+ def _apply_decision(self, decision) -> None:
66
+ if decision.action is StrategyAction.EARLY_STOP:
67
+ self.state.stopped_early = True
68
+ self.state.stop_reason = decision.reason
69
+ return
70
+ if decision.action is StrategyAction.NO_CHANGE:
71
+ return
72
+ self.backend.apply_strategy(decision.resulting_strategy)
73
+ self.state.record_strategy(decision.resulting_strategy)
74
+
75
+ def should_stop(self) -> bool:
76
+ return self.state.stopped_early