Skip to content

Commit f4d283d

Browse files
Magsschcursoragent
andauthored
[THIS-1049] Neat alpha autofix infrastructure (#1569)
# Description Add core infrastructure for automatic fixing data models. Introduces `FixAction` (immutable data model for field-level changes), `FixApplicator` (groups fixes by resource, checks for conflicts, and applies them efficiently via in-place mutation of a deep copy), and `transform_physical` on `NeatStore` to integrate fix application into the provenance pipeline. The orchestrator and session layer are wired up to support the read-validate-fix flow behind an alpha feature flag. The code path for fix functionality is disabled until exposed in a later PR. ## Bump - [ ] Patch - [x] Skip --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0f7f8fa commit f4d283d

File tree

14 files changed

+427
-14
lines changed

14 files changed

+427
-14
lines changed

cognite/neat/_data_model/_fix.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pydantic import BaseModel, ConfigDict, Field
2+
3+
from cognite.neat._data_model.deployer.data_classes import FieldChange
4+
from cognite.neat._data_model.models.dms import SchemaResourceId
5+
6+
7+
class FixAction(BaseModel):
8+
"""An atomic, individually-applicable fix for a schema issue.
9+
10+
Attributes:
11+
resource_id: Reference to the resource being modified.
12+
changes: List of field-level changes.
13+
message: Human-readable description of what this fix does.
14+
code: The validator code (e.g., "NEAT-DMS-PERFORMANCE-001") for grouping in UI.
15+
"""
16+
17+
model_config = ConfigDict(frozen=True)
18+
19+
resource_id: SchemaResourceId
20+
changes: tuple[FieldChange, ...] = Field(default_factory=tuple)
21+
message: str | None = None
22+
code: str

cognite/neat/_data_model/_shared.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from abc import ABC, abstractmethod
22
from typing import Any
33

4+
from cognite.neat._data_model._fix import FixAction
45
from cognite.neat._data_model.deployer.data_classes import DeploymentResult
56
from cognite.neat._issues import IssueList
67

@@ -19,14 +20,29 @@ class OnSuccessIssuesChecker(OnSuccess, ABC):
1920

2021
def __init__(self) -> None:
2122
self._issues = IssueList()
23+
self._pending_fixes: list[FixAction] = []
2224
self._has_run = False
2325

26+
@property
27+
def pending_fixes(self) -> list[FixAction]:
28+
"""Return collected fix actions. Subclasses that support fixing should populate _pending_fixes."""
29+
if not self._has_run:
30+
raise RuntimeError(f"{type(self).__name__} has not been run yet.")
31+
return self._pending_fixes
32+
2433
@property
2534
def issues(self) -> IssueList:
2635
if not self._has_run:
2736
raise RuntimeError(f"{type(self).__name__} has not been run yet.")
2837
return IssueList(self._issues)
2938

39+
def copy(self) -> "OnSuccessIssuesChecker":
40+
"""
41+
Create a new instance of this handler with the same configuration but with a clean state.
42+
This is used to enable re-running the handler after the data model state has been modified.
43+
"""
44+
raise NotImplementedError(f"{type(self).__name__} does not support copying instances.")
45+
3046

3147
class OnSuccessResultProducer(OnSuccess, ABC):
3248
"""Abstract base class for post-activity success handlers that produce desired outcomes using the data model."""

cognite/neat/_data_model/models/dms/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from cognite.neat._data_model.models.dms._space import Space, SpaceRequest, SpaceResponse
4646

4747
from ._data_model import DataModelRequest, DataModelResponse
48-
from ._http import DataModelBody, DataModelResource, ResourceId, T_DataModelResource, T_ResourceId
48+
from ._http import DataModelBody, DataModelResource, ResourceId, SchemaResourceId, T_DataModelResource, T_ResourceId
4949
from ._references import (
5050
ContainerConstraintReference,
5151
ContainerDirectReference,
@@ -171,6 +171,7 @@
171171
"RequiresConstraintDefinition",
172172
"Resource",
173173
"ResourceId",
174+
"SchemaResourceId",
174175
"SequenceCDFExternalIdReference",
175176
"SequenceCDFExternalIdReference",
176177
"SingleEdgeProperty",

cognite/neat/_data_model/models/dms/_http.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,11 @@
2020

2121
T_DataModelResource = TypeVar("T_DataModelResource", bound=DataModelResource)
2222

23-
ResourceId: TypeAlias = (
24-
SpaceReference
25-
| DataModelReference
26-
| ViewReference
27-
| ContainerReference
28-
| ContainerIndexReference
29-
| ContainerConstraintReference
30-
)
23+
# Top-level schema resources (spaces, data models, views, containers)
24+
SchemaResourceId: TypeAlias = SpaceReference | DataModelReference | ViewReference | ContainerReference
25+
26+
# All resource IDs including nested refs (used by deployer for API responses)
27+
ResourceId: TypeAlias = SchemaResourceId | ContainerIndexReference | ContainerConstraintReference
3128

3229
T_ResourceId = TypeVar("T_ResourceId", bound=ResourceId)
3330

cognite/neat/_data_model/rules/_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import ClassVar
33

44
from cognite.neat._data_model._analysis import ValidationResources
5-
from cognite.neat._data_model.models.dms._schema import RequestSchema
5+
from cognite.neat._data_model._fix import FixAction
66
from cognite.neat._issues import ConsistencyError, Recommendation
77

88

@@ -25,7 +25,7 @@ def validate(self) -> list[ConsistencyError] | list[Recommendation] | list[Consi
2525
"""Execute rule validation."""
2626
...
2727

28-
def fix(self) -> RequestSchema:
28+
def fix(self) -> list[FixAction]:
2929
"""Fix the issues found by the validator producing a fixed object."""
3030

3131
raise NotImplementedError("This rule does not implement fix()")

cognite/neat/_data_model/rules/dms/_orchestrator.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,20 @@ def run(self, request_schema: RequestSchema) -> None:
4747
continue
4848
if self._can_run_validator(validator.code, validator.issue_type):
4949
self._issues.extend(validator.validate())
50+
if validator.fixable:
51+
self._pending_fixes.extend(validator.fix())
5052

5153
self._has_run = True
5254

55+
def copy(self) -> "DmsDataModelRulesOrchestrator":
56+
return DmsDataModelRulesOrchestrator(
57+
cdf_snapshot=self._cdf_snapshot,
58+
limits=self._limits,
59+
modus_operandi=self._modus_operandi,
60+
can_run_validator=self._can_run_validator,
61+
enable_alpha_validators=self._enable_alpha_validators,
62+
)
63+
5364
def _gather_validation_resources(self, request_schema: RequestSchema) -> ValidationResources:
5465
# we do not want to modify the original request schema during validation
5566
copy = request_schema.model_copy(deep=True)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from ._base import Transformer
2+
from ._fix_applicator import FixApplicator
3+
4+
__all__ = ["FixApplicator", "Transformer"]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from abc import ABC, abstractmethod
2+
3+
from cognite.neat._data_model.models.dms._schema import RequestSchema
4+
5+
6+
class Transformer(ABC):
7+
"""Abstract base class for data model transformers."""
8+
9+
@abstractmethod
10+
def transform(self, data_model: RequestSchema) -> RequestSchema:
11+
"""Transform and return the modified data model."""
12+
raise NotImplementedError()
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from collections import defaultdict
2+
3+
from cognite.neat._data_model._fix import FixAction
4+
from cognite.neat._data_model.deployer.data_classes import (
5+
AddedField,
6+
ChangedField,
7+
FieldChange,
8+
PrimitiveField,
9+
RemovedField,
10+
)
11+
from cognite.neat._data_model.models.dms import (
12+
ContainerReference,
13+
DataModelReference,
14+
DataModelResource,
15+
SchemaResourceId,
16+
SpaceReference,
17+
ViewReference,
18+
)
19+
from cognite.neat._data_model.models.dms._schema import RequestSchema
20+
from cognite.neat._data_model.transformers._base import Transformer
21+
22+
23+
class FixApplicator(Transformer):
24+
"""Applies the changes in FixAction objects to a schema."""
25+
26+
def __init__(self, fix_actions: list[FixAction]) -> None:
27+
self._fix_actions = fix_actions
28+
29+
def transform(self, data_model: RequestSchema) -> RequestSchema:
30+
"""Apply fix actions and return the fixed schema (a deep copy of the original)."""
31+
result = data_model.model_copy(deep=True)
32+
33+
if not self._fix_actions:
34+
return result
35+
36+
fix_by_resource_id: dict[SchemaResourceId, list[FixAction]] = defaultdict(list)
37+
for action in self._fix_actions:
38+
fix_by_resource_id[action.resource_id].append(action)
39+
40+
resources_list_lookup: dict[type, dict[SchemaResourceId, DataModelResource]] = {
41+
ViewReference: {view.as_reference(): view for view in result.views},
42+
ContainerReference: {container.as_reference(): container for container in result.containers},
43+
SpaceReference: {space.as_reference(): space for space in result.spaces},
44+
DataModelReference: {result.data_model.as_reference(): result.data_model},
45+
}
46+
47+
for resource_id, actions in fix_by_resource_id.items():
48+
resource_lookup = resources_list_lookup.get(type(resource_id))
49+
if resource_lookup is None:
50+
raise RuntimeError(
51+
f"{type(self).__name__}: Unsupported resource type {type(resource_id)}. This is a bug in NEAT."
52+
)
53+
resource = resource_lookup.get(resource_id)
54+
if resource is None:
55+
raise RuntimeError(
56+
f"{type(self).__name__}: Resource {resource_id} not found in schema. This is a bug in NEAT."
57+
)
58+
59+
all_changes_for_resource = [change for action in actions for change in action.changes]
60+
self._check_no_field_path_conflicts(all_changes_for_resource)
61+
self._apply_changes_to_resource(resource, all_changes_for_resource)
62+
63+
return result
64+
65+
def _apply_changes_to_resource(self, resource: DataModelResource, changes: list[FieldChange]) -> None:
66+
"""Apply field changes to the resource in place."""
67+
for change in changes:
68+
if not isinstance(change, PrimitiveField):
69+
raise RuntimeError(
70+
f"{type(self).__name__}: Only primitive field changes are supported, "
71+
f"got {type(change).__name__}. This is a bug in NEAT."
72+
)
73+
if "." not in change.field_path:
74+
raise RuntimeError(
75+
f"{type(self).__name__}: Invalid field_path '{change.field_path}' "
76+
"(expected 'field_name.identifier' format). This is a bug in NEAT."
77+
)
78+
field_name, identifier = change.field_path.split(".", maxsplit=1)
79+
field_map = getattr(resource, field_name, None)
80+
if field_map is None:
81+
field_map = {}
82+
setattr(resource, field_name, field_map)
83+
if isinstance(change, RemovedField):
84+
field_map.pop(identifier, None)
85+
elif isinstance(change, AddedField | ChangedField):
86+
field_map[identifier] = change.new_value
87+
if not field_map:
88+
setattr(resource, field_name, None)
89+
90+
def _check_no_field_path_conflicts(self, changes: list[FieldChange]) -> None:
91+
"""Raise if any changes touch a field_path already modified by a previous change."""
92+
seen_paths: set[str] = set()
93+
for change in changes:
94+
if change.field_path in seen_paths:
95+
raise RuntimeError(
96+
f"{type(self).__name__}: Conflicting fixes — multiple changes "
97+
f"to '{change.field_path}'. This is a bug in NEAT."
98+
)
99+
seen_paths.add(change.field_path)

cognite/neat/_state_machine/_states.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from cognite.neat._data_model.exporters import DMSExporter
44
from cognite.neat._data_model.importers import DMSImporter
5+
from cognite.neat._data_model.transformers import Transformer
56

67
from ._base import State
78

@@ -46,7 +47,7 @@ class PhysicalState(State):
4647
"""
4748

4849
def transition(self, event: Any) -> State:
49-
if isinstance(event, DMSExporter):
50+
if isinstance(event, DMSExporter | Transformer):
5051
return PhysicalState()
5152

5253
return ForbiddenState(self)

0 commit comments

Comments
 (0)