nextmv 0.32.0__py3-none-any.whl → 0.33.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.
nextmv/__about__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "v0.32.0"
1
+ __version__ = "v0.33.0"
nextmv/__init__.py CHANGED
@@ -21,6 +21,7 @@ from .manifest import Manifest as Manifest
21
21
  from .manifest import ManifestBuild as ManifestBuild
22
22
  from .manifest import ManifestOption as ManifestOption
23
23
  from .manifest import ManifestPython as ManifestPython
24
+ from .manifest import ManifestPythonArch as ManifestPythonArch
24
25
  from .manifest import ManifestPythonModel as ManifestPythonModel
25
26
  from .manifest import ManifestRuntime as ManifestRuntime
26
27
  from .manifest import ManifestType as ManifestType
nextmv/cloud/__init__.py CHANGED
@@ -47,6 +47,7 @@ from .acceptance_test import MetricParams as MetricParams
47
47
  from .acceptance_test import MetricResult as MetricResult
48
48
  from .acceptance_test import MetricStatistics as MetricStatistics
49
49
  from .acceptance_test import MetricTolerance as MetricTolerance
50
+ from .acceptance_test import MetricToleranceType as MetricToleranceType
50
51
  from .acceptance_test import MetricType as MetricType
51
52
  from .acceptance_test import ResultStatistics as ResultStatistics
52
53
  from .acceptance_test import StatisticType as StatisticType
@@ -63,6 +64,12 @@ from .batch_experiment import BatchExperimentRun as BatchExperimentRun
63
64
  from .batch_experiment import ExperimentStatus as ExperimentStatus
64
65
  from .client import Client as Client
65
66
  from .client import get_size as get_size
67
+ from .ensemble import EnsembleDefinition as EnsembleDefinition
68
+ from .ensemble import EvaluationRule as EvaluationRule
69
+ from .ensemble import RuleObjective as RuleObjective
70
+ from .ensemble import RuleTolerance as RuleTolerance
71
+ from .ensemble import RuleToleranceType as RuleToleranceType
72
+ from .ensemble import RunGroup as RunGroup
66
73
  from .input_set import InputSet as InputSet
67
74
  from .input_set import ManagedInput as ManagedInput
68
75
  from .instance import Instance as Instance
@@ -47,6 +47,7 @@ from typing import Optional
47
47
 
48
48
  from nextmv.base_model import BaseModel
49
49
  from nextmv.cloud.batch_experiment import ExperimentStatus
50
+ from nextmv.deprecated import deprecated
50
51
 
51
52
 
52
53
  class MetricType(str, Enum):
@@ -214,6 +215,9 @@ class Comparison(str, Enum):
214
215
 
215
216
  class ToleranceType(str, Enum):
216
217
  """
218
+ !!! warning
219
+ `ToleranceType` is deprecated, use `MetricToleranceType` instead.
220
+
217
221
  Type of tolerance used for a metric.
218
222
 
219
223
  You can import the `ToleranceType` class directly from `cloud`:
@@ -242,6 +246,66 @@ class ToleranceType(str, Enum):
242
246
  <ToleranceType.absolute: 'absolute'>
243
247
  """
244
248
 
249
+ undefined = ""
250
+ """ToleranceType is deprecated, please use MetricToleranceType instead.
251
+ Undefined tolerance type."""
252
+ absolute = "absolute"
253
+ """ToleranceType is deprecated, please use MetricToleranceType instead.
254
+ Absolute tolerance type."""
255
+ relative = "relative"
256
+ """ToleranceType is deprecated, please use MetricToleranceType instead.
257
+ Relative tolerance type."""
258
+
259
+
260
+ # Override __getattribute__ to emit deprecation warnings when enum values are accessed
261
+ _original_getattribute = ToleranceType.__class__.__getattribute__
262
+
263
+
264
+ def _deprecated_getattribute(cls, name: str):
265
+ # Only emit deprecation warning if this is specifically the ToleranceType class
266
+ if cls is ToleranceType and name in ("undefined", "absolute", "relative"):
267
+ deprecated(
268
+ f"ToleranceType.{name}",
269
+ "ToleranceType is deprecated and will be removed in a future version. "
270
+ "Please use MetricToleranceType instead",
271
+ )
272
+
273
+ return _original_getattribute(cls, name)
274
+
275
+
276
+ ToleranceType.__class__.__getattribute__ = _deprecated_getattribute
277
+
278
+
279
+ class MetricToleranceType(str, Enum):
280
+ """
281
+ Type of tolerance used for a metric.
282
+
283
+ You can import the `MetricToleranceType` class directly from `cloud`:
284
+
285
+ ```python
286
+ from nextmv.cloud import MetricToleranceType
287
+ ```
288
+
289
+ This enumeration defines the different types of tolerances that can be used
290
+ when comparing metrics in acceptance tests.
291
+
292
+ Attributes
293
+ ----------
294
+ undefined : str
295
+ Undefined tolerance type (empty string).
296
+ absolute : str
297
+ Absolute tolerance type, using a fixed value.
298
+ relative : str
299
+ Relative tolerance type, using a percentage.
300
+
301
+ Examples
302
+ --------
303
+ >>> from nextmv.cloud import MetricToleranceType
304
+ >>> tol_type = MetricToleranceType.absolute
305
+ >>> tol_type
306
+ <MetricToleranceType.absolute: 'absolute'>
307
+ """
308
+
245
309
  undefined = ""
246
310
  """Undefined tolerance type."""
247
311
  absolute = "absolute"
@@ -265,22 +329,22 @@ class MetricTolerance(BaseModel):
265
329
 
266
330
  Attributes
267
331
  ----------
268
- type : ToleranceType
332
+ type : MetricToleranceType
269
333
  Type of tolerance (absolute or relative).
270
334
  value : float
271
335
  Value of the tolerance.
272
336
 
273
337
  Examples
274
338
  --------
275
- >>> from nextmv.cloud import MetricTolerance, ToleranceType
276
- >>> tolerance = MetricTolerance(type=ToleranceType.absolute, value=0.1)
339
+ >>> from nextmv.cloud import MetricTolerance, MetricToleranceType
340
+ >>> tolerance = MetricTolerance(type=MetricToleranceType.absolute, value=0.1)
277
341
  >>> tolerance.type
278
- <ToleranceType.absolute: 'absolute'>
342
+ <MetricToleranceType.absolute: 'absolute'>
279
343
  >>> tolerance.value
280
344
  0.1
281
345
  """
282
346
 
283
- type: ToleranceType
347
+ type: MetricToleranceType
284
348
  """Type of tolerance."""
285
349
  value: float
286
350
  """Value of the tolerance."""
@@ -46,6 +46,7 @@ from nextmv.cloud.batch_experiment import (
46
46
  to_runs,
47
47
  )
48
48
  from nextmv.cloud.client import Client, get_size
49
+ from nextmv.cloud.ensemble import EnsembleDefinition, EvaluationRule, RunGroup
49
50
  from nextmv.cloud.input_set import InputSet, ManagedInput
50
51
  from nextmv.cloud.instance import Instance, InstanceConfiguration
51
52
  from nextmv.cloud.scenario import Scenario, ScenarioInputType, _option_sets, _scenarios_by_id
@@ -129,6 +130,8 @@ class Application:
129
130
  """Base endpoint for the application."""
130
131
  experiments_endpoint: str = "{base}/experiments"
131
132
  """Base endpoint for the experiments in the application."""
133
+ ensembles_endpoint: str = "{base}/ensembles"
134
+ """Base endpoint for managing the ensemble definitions in the application"""
132
135
 
133
136
  def __post_init__(self):
134
137
  """Initialize the endpoint and experiments_endpoint attributes.
@@ -138,6 +141,7 @@ class Application:
138
141
  """
139
142
  self.endpoint = self.endpoint.format(id=self.id)
140
143
  self.experiments_endpoint = self.experiments_endpoint.format(base=self.endpoint)
144
+ self.ensembles_endpoint = self.ensembles_endpoint.format(base=self.endpoint)
141
145
 
142
146
  @classmethod
143
147
  def new(
@@ -515,6 +519,30 @@ class Application:
515
519
  endpoint=f"{self.experiments_endpoint}/batch/{batch_id}",
516
520
  )
517
521
 
522
+ def delete_ensemble_definition(self, ensemble_definition_id: str) -> None:
523
+ """
524
+ Delete an ensemble definition.
525
+
526
+ Parameters
527
+ ----------
528
+ ensemble_definition_id : str
529
+ ID of the ensemble definition to delete.
530
+
531
+ Raises
532
+ ------
533
+ requests.HTTPError
534
+ If the response status code is not 2xx.
535
+
536
+ Examples
537
+ --------
538
+ >>> app.delete_ensemble_definition("development-ensemble-definition")
539
+ """
540
+
541
+ _ = self.client.request(
542
+ method="DELETE",
543
+ endpoint=f"{self.ensembles_endpoint}/{ensemble_definition_id}",
544
+ )
545
+
518
546
  def delete_scenario_test(self, scenario_test_id: str) -> None:
519
547
  """
520
548
  Delete a scenario test.
@@ -563,6 +591,39 @@ class Application:
563
591
  endpoint=f"{self.endpoint}/secrets/{secrets_collection_id}",
564
592
  )
565
593
 
594
+ def ensemble_definition(self, ensemble_definition_id: str) -> EnsembleDefinition:
595
+ """
596
+ Get an ensemble definition.
597
+
598
+ Parameters
599
+ ----------
600
+ ensemble_definition_id : str
601
+ ID of the ensemble definition to retrieve.
602
+
603
+ Returns
604
+ -------
605
+ EnsembleDefintion
606
+ The requested ensemble definition details.
607
+
608
+ Raises
609
+ ------
610
+ requests.HTTPError
611
+ If the response status code is not 2xx.
612
+
613
+ Examples
614
+ --------
615
+ >>> ensemble_definition = app.ensemble_definition("instance-123")
616
+ >>> print(ensemble_definition.name)
617
+ 'Production Ensemble Definition'
618
+ """
619
+
620
+ response = self.client.request(
621
+ method="GET",
622
+ endpoint=f"{self.ensembles_endpoint}/{ensemble_definition_id}",
623
+ )
624
+
625
+ return EnsembleDefinition.from_dict(response.json())
626
+
566
627
  @staticmethod
567
628
  def exists(client: Client, id: str) -> bool:
568
629
  """
@@ -748,6 +809,36 @@ class Application:
748
809
 
749
810
  return [BatchExperimentMetadata.from_dict(batch_experiment) for batch_experiment in response.json()]
750
811
 
812
+ def list_ensemble_definitions(self) -> list[EnsembleDefinition]:
813
+ """
814
+ List all ensemble_definitions.
815
+
816
+ Returns
817
+ -------
818
+ list[EnsembleDefinition]
819
+ List of all ensemble definitions associated with this application.
820
+
821
+ Raises
822
+ ------
823
+ requests.HTTPError
824
+ If the response status code is not 2xx.
825
+
826
+ Examples
827
+ --------
828
+ >>> ensemble_definitions = app.list_ensemble_definitions()
829
+ >>> for ensemble_definition in ensemble_definitions:
830
+ ... print(ensemble_definition.name)
831
+ 'Development Ensemble Definition'
832
+ 'Production Ensemble Definition'
833
+ """
834
+
835
+ response = self.client.request(
836
+ method="GET",
837
+ endpoint=f"{self.ensembles_endpoint}",
838
+ )
839
+
840
+ return [EnsembleDefinition.from_dict(ensemble_definition) for ensemble_definition in response.json()["items"]]
841
+
751
842
  def list_input_sets(self) -> list[InputSet]:
752
843
  """
753
844
  List all input sets.
@@ -1314,6 +1405,53 @@ class Application:
1314
1405
 
1315
1406
  return self.batch_experiment_with_polling(batch_id=batch_id, polling_options=polling_options)
1316
1407
 
1408
+ def new_ensemble_defintion(
1409
+ self,
1410
+ id: str,
1411
+ run_groups: list[RunGroup],
1412
+ rules: list[EvaluationRule],
1413
+ name: Optional[str] = None,
1414
+ description: Optional[str] = None,
1415
+ ) -> EnsembleDefinition:
1416
+ """
1417
+ Create a new ensemble definition.
1418
+
1419
+ Parameters
1420
+ ----------
1421
+ id: str
1422
+ ID of the ensemble defintion.
1423
+ run_groups: list[RunGroup]
1424
+ Information to facilitate the execution of child runs.
1425
+ rules: list[EvaluationRule]
1426
+ Information to facilitate the selection of
1427
+ a result for the ensemble run from child runs.
1428
+ name: Optional[str]
1429
+ Name of the ensemble definition.
1430
+ description: Optional[str]
1431
+ Description of the ensemble definition.
1432
+ """
1433
+
1434
+ if name is None:
1435
+ name = id
1436
+ if description is None:
1437
+ description = name
1438
+
1439
+ payload = {
1440
+ "id": id,
1441
+ "run_groups": [run_group.to_dict() for run_group in run_groups],
1442
+ "rules": [rule.to_dict() for rule in rules],
1443
+ "name": name,
1444
+ "description": description,
1445
+ }
1446
+
1447
+ response = self.client.request(
1448
+ method="POST",
1449
+ endpoint=f"{self.ensembles_endpoint}",
1450
+ payload=payload,
1451
+ )
1452
+
1453
+ return EnsembleDefinition.from_dict(response.json())
1454
+
1317
1455
  def new_input_set(
1318
1456
  self,
1319
1457
  id: str,
@@ -2236,13 +2374,14 @@ class Application:
2236
2374
 
2237
2375
  if id is None:
2238
2376
  id = safe_id(prefix="version")
2377
+ if name is None:
2378
+ name = id
2239
2379
 
2240
2380
  payload = {
2241
2381
  "id": id,
2382
+ "name": name,
2242
2383
  }
2243
2384
 
2244
- if name is not None:
2245
- payload["name"] = name
2246
2385
  if description is not None:
2247
2386
  payload["description"] = description
2248
2387
 
@@ -2934,6 +3073,98 @@ class Application:
2934
3073
  output_dir_path=output_dir_path,
2935
3074
  )
2936
3075
 
3076
+ def update_batch_experiment(
3077
+ self,
3078
+ batch_experiment_id: str,
3079
+ name: Optional[str] = None,
3080
+ description: Optional[str] = None,
3081
+ ) -> BatchExperimentInformation:
3082
+ """
3083
+ Update a batch experiment.
3084
+
3085
+ Parameters
3086
+ ----------
3087
+ batch_experiment_id : str
3088
+ ID of the batch experiment to update.
3089
+ name : Optional[str], default=None
3090
+ Optional name of the batch experiment.
3091
+ description : Optional[str], default=None
3092
+ Optional description of the batch experiment.
3093
+
3094
+ Returns
3095
+ -------
3096
+ BatchExperimentInformation
3097
+ The information with the updated batch experiment.
3098
+
3099
+ Raises
3100
+ ------
3101
+ requests.HTTPError
3102
+ If the response status code is not 2xx.
3103
+ """
3104
+
3105
+ payload = {}
3106
+
3107
+ if name is not None:
3108
+ payload["name"] = name
3109
+ if description is not None:
3110
+ payload["description"] = description
3111
+
3112
+ response = self.client.request(
3113
+ method="PATCH",
3114
+ endpoint=f"{self.experiments_endpoint}/batch/{batch_experiment_id}",
3115
+ payload=payload,
3116
+ )
3117
+
3118
+ return BatchExperimentInformation.from_dict(response.json())
3119
+
3120
+ def update_ensemble_definition(
3121
+ self,
3122
+ id: str,
3123
+ name: Optional[str] = None,
3124
+ description: Optional[str] = None,
3125
+ ) -> EnsembleDefinition:
3126
+ """
3127
+ Update an ensemble definition.
3128
+
3129
+ Parameters
3130
+ ----------
3131
+ id : str
3132
+ ID of the ensemble definition to update.
3133
+ name : Optional[str], default=None
3134
+ Optional name of the ensemble definition.
3135
+ description : Optional[str], default=None
3136
+ Optional description of the ensemble definition.
3137
+
3138
+ Returns
3139
+ -------
3140
+ EnsembleDefinition
3141
+ The updated ensemble definition.
3142
+
3143
+ Raises
3144
+ ------
3145
+ ValueError
3146
+ If neither name nor description is updated
3147
+ requests.HTTPError
3148
+ If the response status code is not 2xx.
3149
+ """
3150
+
3151
+ payload = {}
3152
+
3153
+ if name is None and description is None:
3154
+ raise ValueError("Must define at least one value among name and description to modify")
3155
+ if name is not None:
3156
+ payload["name"] = name
3157
+ if description is not None:
3158
+ payload["description"] = description
3159
+
3160
+ response = self.client.request(
3161
+ method="PATCH",
3162
+ endpoint=f"{self.ensembles_endpoint}/{id}",
3163
+ payload=payload,
3164
+ )
3165
+
3166
+ return EnsembleDefinition.from_dict(response.json())
3167
+
2937
3168
  def update_instance(
2938
3169
  self,
2939
3170
  id: str,
@@ -2997,50 +3228,6 @@ class Application:
2997
3228
 
2998
3229
  return Instance.from_dict(response.json())
2999
3230
 
3000
- def update_batch_experiment(
3001
- self,
3002
- batch_experiment_id: str,
3003
- name: Optional[str] = None,
3004
- description: Optional[str] = None,
3005
- ) -> BatchExperimentInformation:
3006
- """
3007
- Update a batch experiment.
3008
-
3009
- Parameters
3010
- ----------
3011
- batch_experiment_id : str
3012
- ID of the batch experiment to update.
3013
- name : Optional[str], default=None
3014
- Optional name of the batch experiment.
3015
- description : Optional[str], default=None
3016
- Optional description of the batch experiment.
3017
-
3018
- Returns
3019
- -------
3020
- BatchExperimentInformation
3021
- The information with the updated batch experiment.
3022
-
3023
- Raises
3024
- ------
3025
- requests.HTTPError
3026
- If the response status code is not 2xx.
3027
- """
3028
-
3029
- payload = {}
3030
-
3031
- if name is not None:
3032
- payload["name"] = name
3033
- if description is not None:
3034
- payload["description"] = description
3035
-
3036
- response = self.client.request(
3037
- method="PATCH",
3038
- endpoint=f"{self.experiments_endpoint}/batch/{batch_experiment_id}",
3039
- payload=payload,
3040
- )
3041
-
3042
- return BatchExperimentInformation.from_dict(response.json())
3043
-
3044
3231
  def update_managed_input(
3045
3232
  self,
3046
3233
  managed_input_id: str,
@@ -3681,65 +3868,40 @@ class Application:
3681
3868
  """
3682
3869
  Auxiliary function to validate the directory path and configuration.
3683
3870
  """
3871
+ input_type = self.__get_input_type(configuration)
3684
3872
 
3685
- if input_dir_path is None or input_dir_path == "":
3873
+ # If no explicit input type is defined, there is nothing to validate.
3874
+ if input_type is None:
3686
3875
  return
3687
3876
 
3688
- if configuration is None:
3689
- raise ValueError(
3690
- "If dir_path is provided, a RunConfiguration must also be provided.",
3691
- )
3692
-
3693
- config_format = self.__extract_config_format(configuration)
3694
-
3695
- if config_format is None:
3696
- raise ValueError(
3697
- "If dir_path is provided, RunConfiguration.format must also be provided.",
3698
- )
3699
-
3700
- input_type = self.__extract_input_type(config_format)
3701
-
3702
- if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
3877
+ # Validate that the input directory path is provided when explicitly required.
3878
+ dir_types = (InputFormat.MULTI_FILE, InputFormat.CSV_ARCHIVE)
3879
+ if input_type in dir_types and not input_dir_path:
3703
3880
  raise ValueError(
3704
- "If dir_path is provided, RunConfiguration.format.format_input.input_type must be set to a valid type. "
3705
- f"Valid types are: {[InputFormat.CSV_ARCHIVE, InputFormat.MULTI_FILE]}",
3881
+ f"If RunConfiguration.format.format_input.input_type is set to {input_type}, "
3882
+ "then input_dir_path must be provided.",
3706
3883
  )
3707
3884
 
3708
- def __extract_config_format(self, configuration: Union[RunConfiguration, dict[str, Any]]) -> Any:
3709
- """Extract format from configuration, handling both RunConfiguration objects and dicts."""
3710
- if isinstance(configuration, RunConfiguration):
3711
- return configuration.format
3712
-
3713
- if isinstance(configuration, dict):
3714
- config_format = configuration.get("format")
3715
- if config_format is not None and isinstance(config_format, dict):
3716
- return Format.from_dict(config_format) if hasattr(Format, "from_dict") else config_format
3717
-
3718
- return config_format
3719
-
3720
- raise ValueError("Configuration must be a RunConfiguration object or a dict.")
3721
-
3722
- def __extract_input_type(self, config_format: Any) -> Any:
3723
- """Extract input type from config format."""
3724
- if isinstance(config_format, dict):
3725
- format_input = config_format.get("format_input") or config_format.get("input")
3726
- if format_input is None:
3727
- raise ValueError(
3728
- "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
3729
- )
3885
+ def __get_input_type(self, config: Union[RunConfiguration, dict[str, Any]]) -> Optional[InputFormat]:
3886
+ """
3887
+ Auxiliary function to extract the input type from the run configuration.
3888
+ """
3730
3889
 
3731
- if isinstance(format_input, dict):
3732
- return format_input.get("input_type") or format_input.get("type")
3890
+ if config is None:
3891
+ return None
3733
3892
 
3734
- return getattr(format_input, "input_type", None)
3893
+ if isinstance(config, dict):
3894
+ config = RunConfiguration.from_dict(config)
3735
3895
 
3736
- # Handle Format object
3737
- if config_format.format_input is None:
3738
- raise ValueError(
3739
- "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
3740
- )
3896
+ if (
3897
+ isinstance(config, RunConfiguration)
3898
+ and config.format is not None
3899
+ and config.format.format_input is not None
3900
+ and config.format.format_input.input_type is not None
3901
+ ):
3902
+ return config.format.format_input.input_type
3741
3903
 
3742
- return config_format.format_input.input_type
3904
+ return None
3743
3905
 
3744
3906
  def __package_inputs(self, dir_path: str) -> str:
3745
3907
  """
@@ -0,0 +1,248 @@
1
+ """
2
+ Classes for working with Nextmv Cloud Ensemble Runs.
3
+
4
+ This module provides classes for interacting with ensemble runs in Nextmv Cloud.
5
+ It details the core data structures for ensemble definitions.
6
+
7
+ Classes
8
+ -------
9
+ RunGroup
10
+ A structure to group execution of child runs for an ensemble run.
11
+ RuleObjective
12
+ An enum that specifies the supported evaluation rule objectives.
13
+ ToleranceType
14
+ An enum that specifies the supported tolerance types for evaluation rules.
15
+ RuleTolerance
16
+ A structure for defining tolerance thresholds for an evaluation rule
17
+ EvaluationRule
18
+ A structure to evaluate run results for an ensemble run.
19
+ EnsembleDefinition
20
+ Representation of a Nextmv Cloud Ensemble Definition for an application.
21
+ """
22
+
23
+ from datetime import datetime
24
+ from enum import Enum
25
+ from typing import Optional
26
+
27
+ from nextmv.base_model import BaseModel
28
+
29
+
30
+ class RunGroup(BaseModel):
31
+ """A structure to group child runs for an ensemble run.
32
+
33
+ You can import the `RunGroup` class directly from `cloud`:
34
+
35
+ ```python
36
+ from nextmv.cloud import RunGroup
37
+ ```
38
+
39
+ This class represents a grouping of child runs that share a configuration
40
+ for ensemble run executions.
41
+
42
+ Parameters
43
+ ----------
44
+ id : str
45
+ The unique identifier of the run group.
46
+ instance_id : str
47
+ ID of the app instance that this run group executes on.
48
+ options : dict, optional
49
+ Runtime options/parameters for the application.
50
+ repetitions : int, optional
51
+ The number of times the run is to be repeated on the instance and with
52
+ the options defined in the run group
53
+ """
54
+
55
+ id: str
56
+ """The unique identifier of the run group."""
57
+ instance_id: str
58
+ """ID of the app instance that this run group executes on."""
59
+ options: Optional[dict] = None
60
+ """Runtime options/parameters for the application."""
61
+ repetitions: Optional[int] = None
62
+ """The number of times the run is to be repeated on the instance and with
63
+ the options defined in the run group"""
64
+
65
+
66
+ class RuleObjective(str, Enum):
67
+ """The value of this data determines how a value of a run is optimized to
68
+ determined which ensemble child run is the "best" for a given metric and
69
+ rule, as well as which other ones are within tolerance of that run for the
70
+ purposes of selecting a result for the ensemble run from among the child runs.
71
+
72
+ You can import the `RuleObjective` class directly from `cloud`:
73
+
74
+ ```python
75
+ from nextmv.cloud import RuleObjective
76
+ ```
77
+
78
+ This enum specifies the supported evaluation rule objectives.
79
+
80
+ Attributes
81
+ ----------
82
+ MAXIMIZE : str
83
+ Maximize the value of the evaluated metric.
84
+ MINIMIZE : str
85
+ Minimize the value of the evaluated metric.
86
+ """
87
+
88
+ MAXIMIZE = "maximize"
89
+ """Maximize the value of the evaluated metric."""
90
+ MINIMIZE = "minimize"
91
+ """Minimize the value of the evaluated metric."""
92
+
93
+
94
+ class RuleToleranceType(str, Enum):
95
+ """The type of comparison used to determine if a run metric is within
96
+ tolerance of a the "best" run for that rule and metric
97
+
98
+ You can import the `RuleToleranceType` class directly from `cloud`:
99
+
100
+ ```python
101
+ from nextmv.cloud import RuleToleranceType
102
+ ```
103
+
104
+ This enum specifies the supported tolerance types.
105
+
106
+ Attributes
107
+ ----------
108
+ ABSOLUTE : str
109
+ Uses the absolute difference between the value of the "best" run and
110
+ the run being evaluated for tolerance
111
+ RELATIVE : str
112
+ Uses the the percentage of the "best" run by which the run being
113
+ evaluted for tolerance differs. A value of `1` is 100%.
114
+ """
115
+
116
+ ABSOLUTE = "absolute"
117
+ """Uses the absolute difference between the value of the "best" run and
118
+ the run being evaluated for tolerance"""
119
+ RELATIVE = "relative"
120
+ """Uses the the percentage of the "best" run by which the run being
121
+ evaluted for tolerance differs. A value of `1` is 100%."""
122
+
123
+
124
+ class RuleTolerance(BaseModel):
125
+ """A structure used to determine if a run is within tolerance of of the best
126
+ run (as determined by the objective of the `EvaluationRule` it is defined on).
127
+
128
+ You can import the `RuleTolerance` class directly from `cloud`:
129
+
130
+ ```python
131
+ from nextmv.cloud import RuleTolerance
132
+ ```
133
+
134
+ This class represents the tolerance on a particular evaluation rule by
135
+ which a child run may be selected as the result of an ensemble run.
136
+
137
+ value : float
138
+ The value within which runs can deviate from the "best" run
139
+ for that metric to be considered within tolerance of it.
140
+ type : ToleranceType
141
+ The method by which runs are determined to be within tolerance.
142
+ """
143
+
144
+ value: float
145
+ """The value within which runs can deviate from the "best" run
146
+ for that metric to be considered within tolerance of it."""
147
+ type: RuleToleranceType
148
+ """The method by which runs are determined to be within tolerance."""
149
+
150
+
151
+ class EvaluationRule(BaseModel):
152
+ """A structure to evaluate run results for an ensemble run.
153
+
154
+ You can import the `EvaluationRule` class directly from `cloud`:
155
+
156
+ ```python
157
+ from nextmv.cloud import EvaluationRule
158
+ ```
159
+
160
+ This class represents a rule by which the child runs for an ensemble run
161
+ will be evaluated for the purpose of selecting an optimal result for the
162
+ ensemble run.
163
+
164
+ Parameters
165
+ ----------
166
+ id : str
167
+ The unique identifier of the evaluation rule.
168
+ statistics_path : str
169
+ The path within the statistics of a run output (conforming to Nextmv
170
+ statistics convention and flattened to a string starting with `$` and
171
+ delimited by `.` e.g. `$.result.value`.)
172
+ objective : RuleObjective
173
+ The objective by which runs are optimized for this rule
174
+ tolerance : RuleTolerance
175
+ The tolerance by which runs can be accepted as a potential result
176
+ for an evaluation rule
177
+ index : int, optional
178
+ The index (non-negative integer) of the evalutation rule. Lower indicies
179
+ are evaluated first.
180
+ """
181
+
182
+ id: str
183
+ """The unique identifier of the evaluation rule."""
184
+ statistics_path: str
185
+ """The path within the statistics of a run output (conforming to Nextmv
186
+ statistics convention and flattened to a string starting with `$` and
187
+ delimited by `.` e.g. `$.result.value`.)"""
188
+ objective: RuleObjective
189
+ """The objective by which runs are optimized for this rule"""
190
+ tolerance: RuleTolerance
191
+ """The tolerance by which runs can be accepted as a potential result
192
+ for an evaluation rule"""
193
+ index: int
194
+ """The index (non-negative integer) of the evalutation rule. Lower indicies
195
+ are evaluated first."""
196
+
197
+
198
+ class EnsembleDefinition(BaseModel):
199
+ """An ensemble definition for an application.
200
+
201
+ You can import the `EnsembleDefinition` class directly from `cloud`:
202
+
203
+ ```python
204
+ from nextmv.cloud import EnsembleDefinition
205
+ ```
206
+
207
+ A Nextmv Cloud ensemble definition represents a structure by which an
208
+ application can coordinate and execute, and determine the optimal result of
209
+ an ensemble run.
210
+
211
+ Parameters
212
+ ----------
213
+ id : str
214
+ The unique identifier of the ensemble definition.
215
+ application_id : str
216
+ ID of the application that this ensemble definition belongs to.
217
+ name : str
218
+ Human-readable name of the ensemble definition.
219
+ description : str
220
+ Detailed description of the ensemble definition.
221
+ run_groups : list[RunGroup], optional
222
+ The run groups that structure the execution of an ensemble run
223
+ rules : list[EvaluationRule], optional
224
+ The rules by which ensemble child runs are evaluated
225
+ to find an optimal result.
226
+ created_at : datetime
227
+ Timestamp when the ensemble definition was created.
228
+ updated_at : datetime
229
+ Timestamp when the ensemble definition was last updated.
230
+ """
231
+
232
+ id: str
233
+ """The unique identifier of the ensemble definition."""
234
+ application_id: str
235
+ """ID of the application that this ensemble definition belongs to."""
236
+ name: str = ""
237
+ """Human-readable name of the ensemble definition."""
238
+ description: str = ""
239
+ """Detailed description of the ensemble definition."""
240
+ run_groups: list[RunGroup]
241
+ """The run groups that structure the execution of an ensemble run"""
242
+ rules: list[EvaluationRule]
243
+ """The rules by which ensemble child runs are evaluated
244
+ to find an optimal result."""
245
+ created_at: datetime
246
+ """Timestamp when the ensemble definition was created."""
247
+ updated_at: datetime
248
+ """Timestamp when the ensemble definition was last updated."""
nextmv/cloud/package.py CHANGED
@@ -222,7 +222,7 @@ def __handle_python(
222
222
  __install_dependencies(manifest, app_dir, temp_dir)
223
223
 
224
224
 
225
- def __install_dependencies(
225
+ def __install_dependencies( # noqa: C901 # complexity
226
226
  manifest: Manifest,
227
227
  app_dir: str,
228
228
  temp_dir: str,
@@ -253,31 +253,58 @@ def __install_dependencies(
253
253
  if not os.path.isfile(os.path.join(app_dir, pip_requirements)):
254
254
  raise FileNotFoundError(f"pip requirements file '{pip_requirements}' not found in '{app_dir}'")
255
255
 
256
+ platform_filter = []
257
+ if not manifest.python.arch or manifest.python.arch == "arm64":
258
+ platform_filter.extend(
259
+ [
260
+ "--platform=manylinux2014_aarch64",
261
+ "--platform=manylinux_2_17_aarch64",
262
+ "--platform=manylinux_2_24_aarch64",
263
+ "--platform=manylinux_2_28_aarch64",
264
+ "--platform=linux_aarch64",
265
+ ]
266
+ )
267
+ elif manifest.python.arch == "amd64":
268
+ platform_filter.extend(
269
+ [
270
+ "--platform=manylinux2014_x86_64",
271
+ "--platform=manylinux_2_17_x86_64",
272
+ "--platform=manylinux_2_24_x86_64",
273
+ "--platform=manylinux_2_28_x86_64",
274
+ "--platform=linux_x86_64",
275
+ ]
276
+ )
277
+ else:
278
+ raise Exception(f"unknown architecture '{manifest.python.arch}' specified in manifest")
279
+
280
+ version_filter = ["--python-version=3.11"]
281
+ if manifest.python.version:
282
+ __confirm_python_bundling_version(manifest.python.version)
283
+ version_filter = [f"--python-version={manifest.python.version}"]
284
+
256
285
  py_cmd = __get_python_command()
257
286
  dep_dir = os.path.join(".nextmv", "python", "deps")
258
- command = [
259
- py_cmd,
260
- "-m",
261
- "pip",
262
- "install",
263
- "-r",
264
- pip_requirements,
265
- "--platform=manylinux2014_aarch64",
266
- "--platform=manylinux_2_17_aarch64",
267
- "--platform=manylinux_2_24_aarch64",
268
- "--platform=manylinux_2_28_aarch64",
269
- "--platform=linux_aarch64",
270
- "--only-binary=:all:",
271
- "--python-version=3.11",
272
- "--implementation=cp",
273
- "--upgrade",
274
- "--no-warn-conflicts",
275
- "--target",
276
- os.path.join(temp_dir, dep_dir),
277
- "--no-user", # We explicitly avoid user mode (mainly to fix issues with Windows store Python installations)
278
- "--no-input",
279
- "--quiet",
280
- ]
287
+ command = (
288
+ [
289
+ py_cmd,
290
+ "-m",
291
+ "pip",
292
+ "install",
293
+ "-r",
294
+ pip_requirements,
295
+ "--only-binary=:all:",
296
+ "--implementation=cp",
297
+ "--upgrade",
298
+ "--no-warn-conflicts",
299
+ "--target",
300
+ os.path.join(temp_dir, dep_dir),
301
+ "--no-user", # We explicitly avoid user mode (mainly to fix issues with Windows store Python installations)
302
+ "--no-input",
303
+ "--quiet",
304
+ ]
305
+ + platform_filter
306
+ + version_filter
307
+ )
281
308
  result = subprocess.run(
282
309
  command,
283
310
  cwd=app_dir,
@@ -381,6 +408,17 @@ def __confirm_python_version(output: str) -> None:
381
408
  raise Exception("python version 3.9 or higher is required")
382
409
 
383
410
 
411
+ def __confirm_python_bundling_version(version: str) -> None:
412
+ # Only accept versions in the form "major.minor" where both are integers
413
+ re_version = re.compile(r"^(\d+)\.(\d+)$")
414
+ match = re_version.fullmatch(version)
415
+ if match:
416
+ major, minor = int(match.group(1)), int(match.group(2))
417
+ if major == 3 and minor >= 9:
418
+ return
419
+ raise Exception(f"python version 3.9 or higher is required for bundling, got {version}")
420
+
421
+
384
422
  def __compress_tar(source: str, target: str) -> tuple[str, int]:
385
423
  """Compress the source directory into a tar.gz file in the target"""
386
424
 
nextmv/local/executor.py CHANGED
@@ -32,6 +32,7 @@ import json
32
32
  import os
33
33
  import shutil
34
34
  import subprocess
35
+ import sys
35
36
  import tempfile
36
37
  from datetime import datetime, timezone
37
38
  from typing import Any, Optional, Union
@@ -143,7 +144,7 @@ def execute_run(
143
144
  # supporting a Python-first experience, so we are not summoning
144
145
  # applications that are not Python-based.
145
146
  entrypoint = os.path.join(temp_src, manifest.entrypoint)
146
- args = ["python", entrypoint] + options_args(options)
147
+ args = [sys.executable, entrypoint] + options_args(options)
147
148
 
148
149
  result = subprocess.run(
149
150
  args,
nextmv/manifest.py CHANGED
@@ -49,7 +49,7 @@ from enum import Enum
49
49
  from typing import Any, Optional, Union
50
50
 
51
51
  import yaml
52
- from pydantic import AliasChoices, Field
52
+ from pydantic import AliasChoices, Field, field_validator
53
53
 
54
54
  from nextmv.base_model import BaseModel
55
55
  from nextmv.input import InputFormat
@@ -166,6 +166,43 @@ class ManifestRuntime(str, Enum):
166
166
  Based on the python runtime, it provisions (pre-installs) the Hexaly solver
167
167
  to run Python applications.
168
168
  """
169
+ CUOPT = "ghcr.io/nextmv-io/runtime/cuopt:latest"
170
+ """
171
+ A runtime providing the NVIDIA cuOpt solver.
172
+ """
173
+
174
+
175
+ class ManifestPythonArch(str, Enum):
176
+ """
177
+ Target architecture for bundling Python apps.
178
+
179
+ You can import the `ManifestPythonArch` class directly from `nextmv`:
180
+
181
+ ```python
182
+ from nextmv import ManifestPythonArch
183
+ ```
184
+
185
+ Attributes
186
+ ----------
187
+ ARM64 : str
188
+ ARM 64-bit architecture.
189
+ AMD64 : str
190
+ AMD 64-bit architecture.
191
+
192
+ Examples
193
+ --------
194
+ >>> from nextmv import ManifestPythonArch
195
+ >>> arch = ManifestPythonArch.ARM64
196
+ >>> arch
197
+ <ManifestPythonArch.ARM64: 'arm64'>
198
+ >>> str(arch)
199
+ 'arm64'
200
+ """
201
+
202
+ ARM64 = "arm64"
203
+ """ARM 64-bit architecture."""
204
+ AMD64 = "amd64"
205
+ """AMD 64-bit architecture."""
169
206
 
170
207
 
171
208
  class ManifestBuild(BaseModel):
@@ -325,6 +362,12 @@ class ManifestPython(BaseModel):
325
362
  Contains (additional) Python dependencies that will be bundled with the
326
363
  app.
327
364
  """
365
+ arch: Optional[ManifestPythonArch] = None
366
+ """The architecture this model is meant to run on. One of "arm64" or "amd64". Uses
367
+ "arm64" if not specified."""
368
+ version: Optional[Union[str, float]] = None
369
+ """The Python version this model is meant to run with. Uses "3.11" if not specified.
370
+ """
328
371
  model: Optional[ManifestPythonModel] = None
329
372
  """Information about an encoded decision model.
330
373
 
@@ -332,6 +375,17 @@ class ManifestPython(BaseModel):
332
375
  from the app bundle.
333
376
  """
334
377
 
378
+ @field_validator("version", mode="before")
379
+ @classmethod
380
+ def validate_version(cls, v: Optional[Union[str, float]]) -> Optional[str]:
381
+ # We allow the version to be a float in the manifest for convenience, but we want
382
+ # to store it as a string internally.
383
+ if v is None:
384
+ return None
385
+ if isinstance(v, float):
386
+ return str(v)
387
+ return v
388
+
335
389
 
336
390
  class ManifestOptionUI(BaseModel):
337
391
  """
@@ -1025,7 +1079,12 @@ class Manifest(BaseModel):
1025
1079
 
1026
1080
  def model_post_init(self, __context) -> None:
1027
1081
  if self.entrypoint is None:
1028
- if self.runtime in (ManifestRuntime.PYTHON, ManifestRuntime.HEXALY, ManifestRuntime.PYOMO):
1082
+ if self.runtime in (
1083
+ ManifestRuntime.PYTHON,
1084
+ ManifestRuntime.HEXALY,
1085
+ ManifestRuntime.PYOMO,
1086
+ ManifestRuntime.CUOPT,
1087
+ ):
1029
1088
  self.entrypoint = "./main.py"
1030
1089
  elif self.runtime == ManifestRuntime.DEFAULT:
1031
1090
  self.entrypoint = "./main"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextmv
3
- Version: 0.32.0
3
+ Version: 0.33.0
4
4
  Summary: The all-purpose Python SDK for Nextmv
5
5
  Project-URL: Homepage, https://www.nextmv.io
6
6
  Project-URL: Documentation, https://nextmv-py.docs.nextmv.io/en/latest/nextmv/
@@ -1,12 +1,12 @@
1
- nextmv/__about__.py,sha256=8eaqWsF6AYp-9Iqzy05LB4Lu1iuUyTkZ5v8LPco9uLM,24
1
+ nextmv/__about__.py,sha256=VyKoqs7qvl1UtyON_wE484wOoQ1-D2LWfsRhOrha9gg,24
2
2
  nextmv/__entrypoint__.py,sha256=dA0iwwHtrq6Z9w9FxmxKLoBGLyhe7jWtUAU-Y3PEgHg,1094
3
- nextmv/__init__.py,sha256=pW2HMcJnNSFqF_tj77TtqpddEeCosTKS7NxGNPDp7mU,3620
3
+ nextmv/__init__.py,sha256=uM80mRRs2Ht2dP60DvxR11HmunFJ7EL7XUJHtKQi2qI,3683
4
4
  nextmv/_serialization.py,sha256=JlSl6BL0M2Esf7F89GsGIZ__Pp8RnFRNM0UxYhuuYU4,2853
5
5
  nextmv/base_model.py,sha256=qmJ4AsYr9Yv01HQX_BERrn3229gyoZrYyP9tcyqNfeU,2311
6
6
  nextmv/deprecated.py,sha256=kEVfyQ-nT0v2ePXTNldjQG9uH5IlfQVy3L4tztIxwmU,1638
7
7
  nextmv/input.py,sha256=m9sVfO9ZL3F5i1l8amEtlWlbkekyUP4C3y9DduHWGFs,40211
8
8
  nextmv/logger.py,sha256=kNIbu46MisrzYe4T0hNMpWfRTKKacDVvbtQcNys_c_E,2513
9
- nextmv/manifest.py,sha256=wzGDrGleIgYvRvKyy73acjd68cGdCLD_7x_Dy3UhCaI,44266
9
+ nextmv/manifest.py,sha256=jCu5RUl6bip4kTDh4zaD4PTYNOIGHWKm34kfXD_rpzw,45898
10
10
  nextmv/model.py,sha256=vI3pSV3iTwjRPflar7nAg-6h98XRUyi9II5O2J06-Kc,15018
11
11
  nextmv/options.py,sha256=yPJu5lYMbV6YioMwAXv7ctpZUggLXKlZc9CqIbUFvE4,37895
12
12
  nextmv/output.py,sha256=HdvWYG3gIzwoXquulaEVI4LLchXJDjkbag0BkBPM0vQ,55128
@@ -14,15 +14,16 @@ nextmv/polling.py,sha256=nfefvWI1smm-lIzaXE-4DMlojp6KXIvVi88XLJYUmo8,9724
14
14
  nextmv/run.py,sha256=lAWWkLml1C7wkXSvN8orBjudoz72Qv5ZCHCWY9e7xaY,45813
15
15
  nextmv/safe.py,sha256=VAK4fGEurbLNji4Pg5Okga5XQSbI4aI9JJf95_68Z20,3867
16
16
  nextmv/status.py,sha256=SCDLhh2om3yeO5FxO0x-_RShQsZNXEpjHNdCGdb3VUI,2787
17
- nextmv/cloud/__init__.py,sha256=5B2aMKffI_WSFlVlT-oO7ISMqxJeN9XEoqmy7ghKmOI,4651
18
- nextmv/cloud/acceptance_test.py,sha256=1IQ2w7Imu_NAKknLQYNIIV9i_XIf8tmNHVTAOBEtsU8,25675
17
+ nextmv/cloud/__init__.py,sha256=2wI72lhWq81BYv1OpS0OOTT5-3sivpX0H4z5ANPoLMc,5051
18
+ nextmv/cloud/acceptance_test.py,sha256=ZEzCMrfJF-nUFr1nEr4IDgcoyavPhnanjFuPBJ79tAk,27731
19
19
  nextmv/cloud/account.py,sha256=jIdGNyI3l3dVh2PuriAwAOrEuWRM150WgzxcBMVBNRw,6058
20
- nextmv/cloud/application.py,sha256=xUipgYYzqZpda0242d96zft78ZJsbz7KG361hiTBJvQ,134954
20
+ nextmv/cloud/application.py,sha256=zbirm_bbihnzhKsO8gAO_dJRAJV4FtXsQsSaismgt-I,139298
21
21
  nextmv/cloud/batch_experiment.py,sha256=13ciRpgBabMMTyazfdfEAymD3rTPrTAAorECsANxxuA,10397
22
22
  nextmv/cloud/client.py,sha256=E0DiUb377jvEnpXlRnfT1PGCI0Jm0lTUoX5VqeU91lk,18165
23
+ nextmv/cloud/ensemble.py,sha256=glrRgyRFcEH12fNUhEl1FOo6xOTDEaF478dxfX0wj2Y,8604
23
24
  nextmv/cloud/input_set.py,sha256=NkzA6_hwgD-YwoirzwvZrObIoBTfurry7Os3jo4DyXc,4236
24
25
  nextmv/cloud/instance.py,sha256=SS4tbp0LQMWDaeYpwcNxJei82oi_Hozv1t5i3QGjASY,4024
25
- nextmv/cloud/package.py,sha256=cG75DptN4sxXaT8ruDh2EY2duaK5Jwg16X2LV0BP8ts,13021
26
+ nextmv/cloud/package.py,sha256=Xmt-daAeN9QJKpquV28IiZa2eYCX0P3wSS564JHvrtY,14495
26
27
  nextmv/cloud/scenario.py,sha256=JRFTDiFBcrgud6wE2qDHUu5oO-Ur3zbPYhhB6ONCxTo,14263
27
28
  nextmv/cloud/secrets.py,sha256=fA5cX0jfTsPVZWV7433wzETGlXpWRLHGswuObx9e6FQ,6820
28
29
  nextmv/cloud/url.py,sha256=Fz70ywkWdCLmP21ZBmJwZi5kDbjpmsX_VlwVF_xQeHg,1836
@@ -38,12 +39,12 @@ nextmv/default_app/src/main.py,sha256=WWeN_xl_mcPhICl3rSCvdEjRkFXGmAnej88FhS-fAm
38
39
  nextmv/default_app/src/visuals.py,sha256=WYK_YBnLmYo3TpVev1CpoNCuW5R7hk9QIkeCmvMn1Fs,1014
39
40
  nextmv/local/__init__.py,sha256=6BsoqlK4dw6X11_uKzz9gBPfxKpdiol2FYO8R3X73SE,116
40
41
  nextmv/local/application.py,sha256=qq14ihKxymg5NHqhU56qTu6lVmMYWLGYUJC4MrhWB68,45815
41
- nextmv/local/executor.py,sha256=PFeEZaGhEq9mCaeJbrFBStDpsTWv3j6Z89_SjbnphiE,24321
42
+ nextmv/local/executor.py,sha256=ohAUrIRohcH_qGglK1hSFR0W6bPBSuMAcMwVkW5G4vM,24338
42
43
  nextmv/local/geojson_handler.py,sha256=7FavJdkUonop-yskjis0x3qFGB8A5wZyoBUblw-bVhw,12540
43
44
  nextmv/local/local.py,sha256=wUHuoAXqJIZpTJBh056hmQuiPEjlZvLTR2BC6Ino-WI,2619
44
45
  nextmv/local/plotly_handler.py,sha256=bLb50e3AkVr_W-F6S7lXfeRdN60mG2jk3UElNmhoMWU,1930
45
46
  nextmv/local/runner.py,sha256=hwkITHrQG_J9TzxufnaP1mjLWG-iSsNQD66UFZY4pp4,8602
46
- nextmv-0.32.0.dist-info/METADATA,sha256=1GjISld5ZhyMzugNVUIH8ecX9sLHJU_p3e8qfao2A84,16008
47
- nextmv-0.32.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
48
- nextmv-0.32.0.dist-info/licenses/LICENSE,sha256=ZIbK-sSWA-OZprjNbmJAglYRtl5_K4l9UwAV3PGJAPc,11349
49
- nextmv-0.32.0.dist-info/RECORD,,
47
+ nextmv-0.33.0.dist-info/METADATA,sha256=JRt0lALKQ1i0E_J2AhgozeUtC7kv_YGHsqK_-VDePsw,16008
48
+ nextmv-0.33.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
49
+ nextmv-0.33.0.dist-info/licenses/LICENSE,sha256=ZIbK-sSWA-OZprjNbmJAglYRtl5_K4l9UwAV3PGJAPc,11349
50
+ nextmv-0.33.0.dist-info/RECORD,,