cognite-neat 1.0.5__py3-none-any.whl → 1.0.7__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,195 @@
1
+ import itertools
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from cognite.neat._data_model.deployer.data_classes import (
7
+ AddedField,
8
+ AppliedChanges,
9
+ ChangedField,
10
+ ChangeResult,
11
+ DeploymentResult,
12
+ FieldChange,
13
+ FieldChanges,
14
+ RemovedField,
15
+ ResourceChange,
16
+ )
17
+
18
+
19
+ class SerializedFieldChange(BaseModel):
20
+ """Serialized field change for JSON output."""
21
+
22
+ field_path: str
23
+ severity: str
24
+ description: str
25
+
26
+ @classmethod
27
+ def from_field_change(cls, field_change: FieldChange) -> list["SerializedFieldChange"]:
28
+ """Serialize a field change, handling nested FieldChanges recursively.
29
+
30
+ Args:
31
+ field_change: The field change to serialize.
32
+
33
+ Returns:
34
+ List of serialized field changes (may be multiple if nested).
35
+ """
36
+ serialized_changes: list[SerializedFieldChange] = []
37
+
38
+ if isinstance(field_change, FieldChanges):
39
+ # Recursively handle nested changes
40
+ for change in field_change.changes:
41
+ serialized_changes.extend(cls.from_field_change(change))
42
+ else:
43
+ # Base case: single field change
44
+ serialized_changes.append(cls._from_single_field_change(field_change))
45
+
46
+ return serialized_changes
47
+
48
+ @classmethod
49
+ def _from_single_field_change(cls, field_change: FieldChange) -> "SerializedFieldChange":
50
+ """Serialize a single non-nested field change.
51
+
52
+ Args:
53
+ field_change: The single field change to serialize.
54
+
55
+ Returns:
56
+ Serialized field change.
57
+ """
58
+ return cls(
59
+ field_path=field_change.field_path,
60
+ severity=field_change.severity.name,
61
+ description=field_change.description
62
+ if isinstance(field_change, AddedField | RemovedField | ChangedField)
63
+ else "Field changed",
64
+ )
65
+
66
+
67
+ class SerializedResourceChange(BaseModel):
68
+ """Serialized resource change for JSON output."""
69
+
70
+ id: int
71
+ endpoint: str
72
+ change_type: str
73
+ severity: str
74
+ resource_id: str
75
+ message: str | None = None
76
+ changes: list[SerializedFieldChange] = Field(default_factory=list)
77
+
78
+ @classmethod
79
+ def from_resource_change(
80
+ cls, resource: ResourceChange, endpoint: str, change_id: int
81
+ ) -> "SerializedResourceChange":
82
+ """Serialize a single resource change.
83
+
84
+ Args:
85
+ resource: The resource change to serialize.
86
+ endpoint: The endpoint type.
87
+ change_id: Unique ID for this change.
88
+
89
+ Returns:
90
+ Serialized resource change.
91
+ """
92
+ changes: list[SerializedFieldChange] = []
93
+
94
+ for change in resource.changes:
95
+ changes.extend(SerializedFieldChange.from_field_change(change))
96
+
97
+ return cls(
98
+ id=change_id,
99
+ endpoint=endpoint,
100
+ change_type=resource.change_type,
101
+ severity=resource.severity.name,
102
+ resource_id=str(resource.resource_id),
103
+ changes=changes,
104
+ )
105
+
106
+ @classmethod
107
+ def from_change_result(cls, change_id: int, response: ChangeResult) -> "SerializedResourceChange":
108
+ """Serialize from a change result (actual deployment).
109
+
110
+ Args:
111
+ change_id: Unique ID for this change.
112
+ response: The change result from deployment.
113
+
114
+ Returns:
115
+ Serialized resource change with deployment status.
116
+ """
117
+ serialized_resource_change = cls.from_resource_change(
118
+ resource=response.change,
119
+ endpoint=response.endpoint,
120
+ change_id=change_id,
121
+ )
122
+
123
+ serialized_resource_change.message = response.message
124
+ if not response.is_success:
125
+ serialized_resource_change.change_type = "failed"
126
+
127
+ return serialized_resource_change
128
+
129
+
130
+ class SerializedChanges(BaseModel):
131
+ """Container for all serialized changes."""
132
+
133
+ changes: list[SerializedResourceChange] = Field(default_factory=list)
134
+
135
+ @classmethod
136
+ def from_deployment_result(cls, result: DeploymentResult) -> "SerializedChanges":
137
+ """Create SerializedChanges from a DeploymentResult.
138
+
139
+ Args:
140
+ result: The deployment result to serialize changes from.
141
+
142
+ Returns:
143
+ SerializedChanges instance with all changes.
144
+ """
145
+ serialized = cls()
146
+
147
+ if not result.responses:
148
+ serialized._add_from_dry_run(result)
149
+ else:
150
+ serialized._add_from_applied_changes(result.responses)
151
+
152
+ return serialized
153
+
154
+ def _add_from_dry_run(self, result: DeploymentResult) -> None:
155
+ """Add changes from dry run deployment.
156
+
157
+ Args:
158
+ result: The deployment result in dry run mode.
159
+ """
160
+ # Iterate over each endpoint plan
161
+ for endpoint_plan in result.plan:
162
+ # Then per resource in the endpoint
163
+ for resource in endpoint_plan.resources:
164
+ # Then serialize individual resource change
165
+ serialized_resource_change = SerializedResourceChange.from_resource_change(
166
+ resource=resource,
167
+ endpoint=endpoint_plan.endpoint,
168
+ change_id=len(self.changes),
169
+ )
170
+ self.changes.append(serialized_resource_change)
171
+
172
+ def _add_from_applied_changes(self, applied_changes: AppliedChanges) -> None:
173
+ """Add changes from actual deployment.
174
+ Args:
175
+ result: The deployment result from actual deployment.
176
+ """
177
+ for response in itertools.chain(
178
+ applied_changes.created,
179
+ applied_changes.merged_updated,
180
+ applied_changes.deletions,
181
+ applied_changes.unchanged,
182
+ applied_changes.skipped,
183
+ ):
184
+ self.changes.append(SerializedResourceChange.from_change_result(len(self.changes), response))
185
+
186
+ def model_dump_json_flat(self, **kwargs: Any) -> str:
187
+ """Dump changes as JSON array without the wrapper key.
188
+ Returns:
189
+ JSON string of the changes array.
190
+ """
191
+ if not self.changes:
192
+ return "[]"
193
+
194
+ iterator = (change.model_dump_json(**kwargs) for change in self.changes)
195
+ return f"[{','.join(iterator)}]"
@@ -0,0 +1,180 @@
1
+ import itertools
2
+ from typing import Any, Literal, cast, get_args
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from cognite.neat._data_model.deployer.data_classes import (
7
+ AppliedChanges,
8
+ DataModelEndpoint,
9
+ DeploymentResult,
10
+ )
11
+
12
+
13
+ class EndpointStatistics(BaseModel):
14
+ """Statistics for a single endpoint."""
15
+
16
+ create: int = 0
17
+ update: int = 0
18
+ delete: int = 0
19
+ skip: int = 0
20
+ unchanged: int = 0
21
+ failed: int = 0
22
+
23
+ @property
24
+ def total(self) -> int:
25
+ """Total number of changes for this endpoint."""
26
+ return self.create + self.update + self.delete + self.skip + self.unchanged + self.failed
27
+
28
+ def increment(self, change_type: Literal["create", "update", "delete", "unchanged", "skip", "failed"]) -> None:
29
+ """Increment the count for a specific change type.
30
+
31
+ Args:
32
+ change_type: The type of change (create, update, delete, skip, unchanged, failed).
33
+ """
34
+ if hasattr(self, change_type):
35
+ setattr(self, change_type, getattr(self, change_type) + 1)
36
+ else:
37
+ raise RuntimeError(f"Unknown change type: {change_type}. This is a bug in NEAT.")
38
+
39
+
40
+ class ChangeTypeStatistics(BaseModel):
41
+ """Statistics grouped by change type."""
42
+
43
+ create: int = 0
44
+ update: int = 0
45
+ delete: int = 0
46
+ skip: int = 0
47
+ unchanged: int = 0
48
+ failed: int = 0
49
+
50
+ def increment(self, change_type: Literal["create", "update", "delete", "unchanged", "skip", "failed"]) -> None:
51
+ """Increment the count for a specific change type.
52
+
53
+ Args:
54
+ change_type: The type of change (create, update, delete, skip, unchanged, failed).
55
+ """
56
+ if hasattr(self, change_type):
57
+ setattr(self, change_type, getattr(self, change_type) + 1)
58
+ else:
59
+ raise RuntimeError(f"Unknown change type: {change_type}. This is a bug in NEAT.")
60
+
61
+
62
+ class SeverityStatistics(BaseModel):
63
+ """Statistics grouped by severity."""
64
+
65
+ SAFE: int = 0
66
+ WARNING: int = 0
67
+ BREAKING: int = 0
68
+
69
+ def increment(self, severity: Literal["SAFE", "WARNING", "BREAKING"]) -> None:
70
+ """Increment the count for a specific severity level.
71
+
72
+ Args:
73
+ severity: The severity level (SAFE, WARNING, BREAKING).
74
+ """
75
+ if hasattr(self, severity):
76
+ setattr(self, severity, getattr(self, severity) + 1)
77
+ else:
78
+ raise RuntimeError(f"Unknown severity level: {severity}. This is a bug in NEAT.")
79
+
80
+
81
+ class DeploymentStatistics(BaseModel):
82
+ """Overall deployment statistics."""
83
+
84
+ by_endpoint: dict[str, EndpointStatistics] = Field(default_factory=dict)
85
+ by_change_type: ChangeTypeStatistics = Field(default_factory=ChangeTypeStatistics)
86
+ by_severity: SeverityStatistics = Field(default_factory=SeverityStatistics)
87
+
88
+ @property
89
+ def total_changes(self) -> int:
90
+ """Total number of changes in the deployment."""
91
+ return sum(endpoint_stats.total for endpoint_stats in self.by_endpoint.values())
92
+
93
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
94
+ """Include computed properties in serialization."""
95
+ data = super().model_dump(**kwargs)
96
+ data["total_changes"] = self.total_changes
97
+ return data
98
+
99
+ @classmethod
100
+ def from_deployment_result(cls, result: DeploymentResult) -> "DeploymentStatistics":
101
+ """Create DeploymentStatistics from a DeploymentResult.
102
+
103
+ Args:
104
+ result: The deployment result to create statistics from.
105
+
106
+ Returns:
107
+ DeploymentStatistics instance with computed statistics.
108
+ """
109
+ stats = cls(
110
+ by_endpoint={endpoint: EndpointStatistics() for endpoint in get_args(DataModelEndpoint)},
111
+ by_change_type=ChangeTypeStatistics(),
112
+ by_severity=SeverityStatistics(),
113
+ )
114
+
115
+ if result.is_dry_run:
116
+ stats._update_from_dry_run(result)
117
+ else:
118
+ stats._update_from_deployment(result)
119
+
120
+ return stats
121
+
122
+ def _update_from_dry_run(self, result: DeploymentResult) -> None:
123
+ """Update statistics from dry run mode.
124
+
125
+ Args:
126
+ result: The deployment result in dry run mode.
127
+ """
128
+ for plan in result.plan:
129
+ for resource in plan.resources:
130
+ self._update_single_stat(
131
+ endpoint=plan.endpoint,
132
+ change_type=resource.change_type,
133
+ severity=cast(Literal["SAFE", "WARNING", "BREAKING"], resource.severity.name),
134
+ )
135
+
136
+ def _update_from_deployment(self, result: DeploymentResult) -> None:
137
+ """Update statistics from actual deployment mode.
138
+
139
+ Args:
140
+ result: The deployment result from actual deployment.
141
+ """
142
+ applied_changes = cast(AppliedChanges, result.responses)
143
+
144
+ for response in itertools.chain(
145
+ applied_changes.created,
146
+ applied_changes.merged_updated,
147
+ applied_changes.deletions,
148
+ applied_changes.unchanged,
149
+ applied_changes.skipped,
150
+ ):
151
+ self._update_single_stat(
152
+ endpoint=response.endpoint,
153
+ change_type=response.change.change_type if response.is_success else "failed",
154
+ severity=cast(Literal["SAFE", "WARNING", "BREAKING"], response.change.severity.name),
155
+ )
156
+
157
+ def _update_single_stat(
158
+ self,
159
+ endpoint: str,
160
+ change_type: Literal["create", "update", "delete", "unchanged", "skip", "failed"],
161
+ severity: Literal["SAFE", "WARNING", "BREAKING"],
162
+ ) -> None:
163
+ """Update all statistics for a single change.
164
+
165
+ Args:
166
+ endpoint: The endpoint type (spaces, containers, views, datamodels).
167
+ change_type: The type of change (create, update, delete, skip, unchanged, failed).
168
+ severity: The severity level (SAFE, WARNING, BREAKING).
169
+ """
170
+ # Update by change type statistics
171
+ self.by_change_type.increment(change_type)
172
+
173
+ # Update by endpoint statistics
174
+ if endpoint in self.by_endpoint:
175
+ self.by_endpoint[endpoint].increment(change_type)
176
+ else:
177
+ raise RuntimeError(f"Unknown endpoint: {endpoint}. This is a bug in NEAT.")
178
+
179
+ # Update severity statistics
180
+ self.by_severity.increment(severity)
@@ -0,0 +1,35 @@
1
+ import uuid
2
+ from typing import Any
3
+
4
+ from cognite.neat._data_model.deployer.data_classes import DeploymentResult
5
+
6
+ from ._changes import SerializedChanges
7
+ from ._statistics import DeploymentStatistics
8
+
9
+
10
+ def serialize_deployment_result(result: DeploymentResult) -> dict[str, Any]:
11
+ """Serialize deployment result into structured changes.
12
+
13
+ Args:
14
+ result: The deployment result to serialize.
15
+
16
+ Returns:
17
+ Serialized changes representing the deployment result.
18
+ """
19
+ result_dict = {"unique_id": uuid.uuid4().hex[:8], "status": result.status, "is_dry_run": result.is_dry_run}
20
+
21
+ stats = DeploymentStatistics.from_deployment_result(result)
22
+ changes = SerializedChanges.from_deployment_result(result)
23
+
24
+ result_dict["total_changes"] = stats.total_changes
25
+ result_dict["created"] = stats.by_change_type.create
26
+ result_dict["updated"] = stats.by_change_type.update
27
+ result_dict["deleted"] = stats.by_change_type.delete
28
+ result_dict["skipped"] = stats.by_change_type.skip
29
+ result_dict["unchanged"] = stats.by_change_type.unchanged
30
+ result_dict["failed"] = stats.by_change_type.failed
31
+
32
+ result_dict["STATS_JSON"] = stats.model_dump_json()
33
+ result_dict["CHANGES_JSON"] = changes.model_dump_json_flat()
34
+
35
+ return result_dict
@@ -0,0 +1,31 @@
1
+ from cognite.neat._data_model.deployer.data_classes import DeploymentResult
2
+ from cognite.neat._session._html._render import render
3
+ from cognite.neat._store import NeatStore
4
+
5
+ from ._deployment._physical.serializer import serialize_deployment_result
6
+
7
+
8
+ class Result:
9
+ """Class to handle deployment results in the NeatSession."""
10
+
11
+ def __init__(self, store: NeatStore) -> None:
12
+ self._store = store
13
+
14
+ @property
15
+ def _result(self) -> DeploymentResult | None:
16
+ """Get deployment result from the last change in the store."""
17
+ if change := self._store.provenance.last_change:
18
+ if change.result:
19
+ return change.result
20
+ return None
21
+
22
+ def _repr_html_(self) -> str:
23
+ """Generate interactive HTML representation."""
24
+ if not self._result:
25
+ return "<p>No deployment result available</p>"
26
+
27
+ if isinstance(self._result, DeploymentResult):
28
+ serialized_result = serialize_deployment_result(self._result)
29
+ return render("deployment", serialized_result)
30
+ else:
31
+ raise NotImplementedError(f"HTML rendering for the result type {type(self._result)} is not implemented.")
cognite/neat/_version.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "1.0.5"
1
+ __version__ = "1.0.7"
2
2
  __engine__ = "^2.0.4"
@@ -1,56 +1,56 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cognite-neat
3
- Version: 1.0.5
3
+ Version: 1.0.7
4
4
  Summary: Knowledge graph transformation
5
- Project-URL: Documentation, https://cognite-neat.readthedocs-hosted.com/
6
- Project-URL: Homepage, https://cognite-neat.readthedocs-hosted.com/
7
- Project-URL: GitHub, https://github.com/cognitedata/neat
8
- Project-URL: Changelog, https://github.com/cognitedata/neat/releases
5
+ Author: Nikola Vasiljevic, Anders Albert
9
6
  Author-email: Nikola Vasiljevic <nikola.vasiljevic@cognite.com>, Anders Albert <anders.albert@cognite.com>
10
7
  License-Expression: Apache-2.0
11
- License-File: LICENSE
12
- Requires-Python: >=3.10
13
- Requires-Dist: backports-strenum<2.0.0,>=1.2; python_version < '3.11'
14
- Requires-Dist: cognite-sdk<8.0.0,>=7.83.0
15
- Requires-Dist: elementpath<5.0.0,>=4.0.0
16
- Requires-Dist: exceptiongroup<2.0.0,>=1.1.3; python_version < '3.11'
8
+ Requires-Dist: pandas>=1.5.3,<3.0.0
9
+ Requires-Dist: cognite-sdk>=7.83.0,<8.0.0
10
+ Requires-Dist: rdflib>=7.0.0,<8.0.0
17
11
  Requires-Dist: httpx>=0.28.1
18
- Requires-Dist: jsonpath-python<2.0.0,>=1.0.6
19
- Requires-Dist: mixpanel<5.0.0,>=4.10.1
20
- Requires-Dist: networkx<4.0.0,>=3.4.2
21
- Requires-Dist: openpyxl<4.0.0,>=3.0.10
12
+ Requires-Dist: pydantic>=2.0.0,<3.0.0
13
+ Requires-Dist: pyyaml>=6.0.1,<7.0.0
14
+ Requires-Dist: requests>=2.28.1,<3.0.0
15
+ Requires-Dist: urllib3>=1.26.15,<3.0.0
16
+ Requires-Dist: openpyxl>=3.0.10,<4.0.0
17
+ Requires-Dist: networkx>=3.4.2,<4.0.0
18
+ Requires-Dist: mixpanel>=4.10.1,<5.0.0
22
19
  Requires-Dist: packaging>=22.0
23
- Requires-Dist: pandas<3.0.0,>=1.5.3
24
- Requires-Dist: pydantic<3.0.0,>=2.0.0
25
- Requires-Dist: pyvis<1.0.0,>=0.3.2
26
- Requires-Dist: pyyaml<7.0.0,>=6.0.1
27
- Requires-Dist: rdflib<8.0.0,>=7.0.0
28
- Requires-Dist: requests<3.0.0,>=2.28.1
29
- Requires-Dist: rich[jupyter]<14.0.0,>=13.7.1
30
- Requires-Dist: tomli<3.0.0,>=2.0.1; python_version < '3.11'
31
- Requires-Dist: typing-extensions<5.0.0,>=4.8.0; python_version < '3.11'
32
- Requires-Dist: urllib3<3.0.0,>=1.26.15
20
+ Requires-Dist: jsonpath-python>=1.0.6,<2.0.0
21
+ Requires-Dist: elementpath>=4.0.0,<5.0.0
22
+ Requires-Dist: pyvis>=0.3.2,<1.0.0
23
+ Requires-Dist: rich[jupyter]>=13.7.1,<14.0.0
24
+ Requires-Dist: exceptiongroup>=1.1.3,<2.0.0 ; python_full_version < '3.11'
25
+ Requires-Dist: backports-strenum>=1.2,<2.0.0 ; python_full_version < '3.11'
26
+ Requires-Dist: typing-extensions>=4.8.0,<5.0.0 ; python_full_version < '3.11'
27
+ Requires-Dist: tomli>=2.0.1,<3.0.0 ; python_full_version < '3.11'
28
+ Requires-Dist: mkdocs>=1.4.0,<2.0.0 ; extra == 'docs'
29
+ Requires-Dist: mkdocs-jupyter>=0.25.1,<1.0.0 ; extra == 'docs'
30
+ Requires-Dist: mkdocs-material-extensions>=1.3.1,<2.0.0 ; extra == 'docs'
31
+ Requires-Dist: mkdocs-git-revision-date-localized-plugin ; extra == 'docs'
32
+ Requires-Dist: mkdocs-git-authors-plugin>=0.9.4,<1.0.0 ; extra == 'docs'
33
+ Requires-Dist: mkdocs-gitbook>=0.0.1,<1.0.0 ; extra == 'docs'
34
+ Requires-Dist: mkdocs-glightbox>=0.4.0,<1.0.0 ; extra == 'docs'
35
+ Requires-Dist: pymdown-extensions>=10.14.3,<11.0.0 ; extra == 'docs'
36
+ Requires-Dist: mkdocstrings[python]>=0.25.2,<1.0.0 ; extra == 'docs'
37
+ Requires-Dist: mistune==3.0.2 ; extra == 'docs'
38
+ Requires-Dist: mkdocs-autorefs>=0.5.0,<1.0.0 ; extra == 'docs'
39
+ Requires-Dist: gspread>=5.0.0,<6.0.0 ; extra == 'google'
40
+ Requires-Dist: google-api-python-client>=2.70.0,<3.0.0 ; extra == 'google'
41
+ Requires-Dist: google-auth-oauthlib>=1.0.0,<2.0.0 ; extra == 'google'
42
+ Requires-Dist: lxml>=5.3.0,<6.0.0 ; extra == 'lxml'
43
+ Requires-Dist: oxrdflib>=0.4.0,<0.5.0 ; extra == 'oxi'
44
+ Requires-Dist: pyoxigraph>=0.4.3,<0.5.0 ; extra == 'oxi'
45
+ Requires-Python: >=3.10
46
+ Project-URL: Changelog, https://github.com/cognitedata/neat/releases
47
+ Project-URL: Documentation, https://cognite-neat.readthedocs-hosted.com/
48
+ Project-URL: GitHub, https://github.com/cognitedata/neat
49
+ Project-URL: Homepage, https://cognite-neat.readthedocs-hosted.com/
33
50
  Provides-Extra: docs
34
- Requires-Dist: mistune==3.0.2; extra == 'docs'
35
- Requires-Dist: mkdocs-autorefs<1.0.0,>=0.5.0; extra == 'docs'
36
- Requires-Dist: mkdocs-git-authors-plugin<1.0.0,>=0.9.4; extra == 'docs'
37
- Requires-Dist: mkdocs-git-revision-date-localized-plugin; extra == 'docs'
38
- Requires-Dist: mkdocs-gitbook<1.0.0,>=0.0.1; extra == 'docs'
39
- Requires-Dist: mkdocs-glightbox<1.0.0,>=0.4.0; extra == 'docs'
40
- Requires-Dist: mkdocs-jupyter<1.0.0,>=0.25.1; extra == 'docs'
41
- Requires-Dist: mkdocs-material-extensions<2.0.0,>=1.3.1; extra == 'docs'
42
- Requires-Dist: mkdocs<2.0.0,>=1.4.0; extra == 'docs'
43
- Requires-Dist: mkdocstrings[python]<1.0.0,>=0.25.2; extra == 'docs'
44
- Requires-Dist: pymdown-extensions<11.0.0,>=10.14.3; extra == 'docs'
45
51
  Provides-Extra: google
46
- Requires-Dist: google-api-python-client<3.0.0,>=2.70.0; extra == 'google'
47
- Requires-Dist: google-auth-oauthlib<2.0.0,>=1.0.0; extra == 'google'
48
- Requires-Dist: gspread<6.0.0,>=5.0.0; extra == 'google'
49
52
  Provides-Extra: lxml
50
- Requires-Dist: lxml<6.0.0,>=5.3.0; extra == 'lxml'
51
53
  Provides-Extra: oxi
52
- Requires-Dist: oxrdflib<0.5.0,>=0.4.0; extra == 'oxi'
53
- Requires-Dist: pyoxigraph<0.5.0,>=0.4.3; extra == 'oxi'
54
54
  Description-Content-Type: text/markdown
55
55
 
56
56
  # kNowlEdge grAph Transformer (NEAT)