solverforge 0.6.8__tar.gz → 0.6.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. {solverforge-0.6.8 → solverforge-0.6.9}/Cargo.lock +1 -1
  2. {solverforge-0.6.8 → solverforge-0.6.9}/Cargo.toml +1 -1
  3. {solverforge-0.6.8 → solverforge-0.6.9}/PKG-INFO +23 -10
  4. {solverforge-0.6.8 → solverforge-0.6.9}/README.md +22 -9
  5. {solverforge-0.6.8 → solverforge-0.6.9}/pyproject.toml +1 -1
  6. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/__init__.py +3 -1
  7. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/decorators.py +15 -1
  8. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/fields.py +13 -3
  9. {solverforge-0.6.8 → solverforge-0.6.9}/src/schema/build.rs +18 -1
  10. {solverforge-0.6.8 → solverforge-0.6.9}/src/schema/parse.rs +20 -0
  11. {solverforge-0.6.8 → solverforge-0.6.9}/src/schema/types.rs +4 -0
  12. {solverforge-0.6.8 → solverforge-0.6.9}/src/state/entity_table.rs +5 -0
  13. {solverforge-0.6.8 → solverforge-0.6.9}/src/state/marshal.rs +38 -1
  14. {solverforge-0.6.8 → solverforge-0.6.9}/src/state/solution.rs +1 -0
  15. {solverforge-0.6.8 → solverforge-0.6.9}/LICENSE +0 -0
  16. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/_native.pyi +0 -0
  17. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/config.py +0 -0
  18. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/console.py +0 -0
  19. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/constraints.py +0 -0
  20. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/errors.py +0 -0
  21. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/events.py +0 -0
  22. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/groups.py +0 -0
  23. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/joiner.py +0 -0
  24. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/manager.py +0 -0
  25. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/model.py +0 -0
  26. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/py.typed +0 -0
  27. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/score.py +0 -0
  28. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/solver.py +0 -0
  29. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/typing.py +0 -0
  30. {solverforge-0.6.8 → solverforge-0.6.9}/python/solverforge/ui.py +0 -0
  31. {solverforge-0.6.8 → solverforge-0.6.9}/rust-toolchain.toml +0 -0
  32. {solverforge-0.6.8 → solverforge-0.6.9}/src/bindings.rs +0 -0
  33. {solverforge-0.6.8 → solverforge-0.6.9}/src/callbacks/call.rs +0 -0
  34. {solverforge-0.6.8 → solverforge-0.6.9}/src/callbacks/mod.rs +0 -0
  35. {solverforge-0.6.8 → solverforge-0.6.9}/src/callbacks/registry.rs +0 -0
  36. {solverforge-0.6.8 → solverforge-0.6.9}/src/callbacks/threading.rs +0 -0
  37. {solverforge-0.6.8 → solverforge-0.6.9}/src/config.rs +0 -0
  38. {solverforge-0.6.8 → solverforge-0.6.9}/src/constraints/compiled.rs +0 -0
  39. {solverforge-0.6.8 → solverforge-0.6.9}/src/constraints/evaluate.rs +0 -0
  40. {solverforge-0.6.8 → solverforge-0.6.9}/src/constraints/incremental.rs +0 -0
  41. {solverforge-0.6.8 → solverforge-0.6.9}/src/constraints/list_precedence.rs +0 -0
  42. {solverforge-0.6.8 → solverforge-0.6.9}/src/constraints/matches.rs +0 -0
  43. {solverforge-0.6.8 → solverforge-0.6.9}/src/constraints/mod.rs +0 -0
  44. {solverforge-0.6.8 → solverforge-0.6.9}/src/constraints/state.rs +0 -0
  45. {solverforge-0.6.8 → solverforge-0.6.9}/src/constraints/stream_plan.rs +0 -0
  46. {solverforge-0.6.8 → solverforge-0.6.9}/src/descriptor/extractor.rs +0 -0
  47. {solverforge-0.6.8 → solverforge-0.6.9}/src/descriptor/mod.rs +0 -0
  48. {solverforge-0.6.8 → solverforge-0.6.9}/src/descriptor/variable.rs +0 -0
  49. {solverforge-0.6.8 → solverforge-0.6.9}/src/error.rs +0 -0
  50. {solverforge-0.6.8 → solverforge-0.6.9}/src/intern.rs +0 -0
  51. {solverforge-0.6.8 → solverforge-0.6.9}/src/lib.rs +0 -0
  52. {solverforge-0.6.8 → solverforge-0.6.9}/src/manager/candidate_trace.rs +0 -0
  53. {solverforge-0.6.8 → solverforge-0.6.9}/src/manager/events.rs +0 -0
  54. {solverforge-0.6.8 → solverforge-0.6.9}/src/manager/jobs.rs +0 -0
  55. {solverforge-0.6.8 → solverforge-0.6.9}/src/manager/mod.rs +0 -0
  56. {solverforge-0.6.8 → solverforge-0.6.9}/src/manager/provenance.rs +0 -0
  57. {solverforge-0.6.8 → solverforge-0.6.9}/src/proxy/entity.rs +0 -0
  58. {solverforge-0.6.8 → solverforge-0.6.9}/src/proxy/list.rs +0 -0
  59. {solverforge-0.6.8 → solverforge-0.6.9}/src/proxy/mod.rs +0 -0
  60. {solverforge-0.6.8 → solverforge-0.6.9}/src/proxy/solution.rs +0 -0
  61. {solverforge-0.6.8 → solverforge-0.6.9}/src/runtime/candidate_metric.rs +0 -0
  62. {solverforge-0.6.8 → solverforge-0.6.9}/src/runtime/dynamic_assignment_group.rs +0 -0
  63. {solverforge-0.6.8 → solverforge-0.6.9}/src/runtime/dynamic_provider.rs +0 -0
  64. {solverforge-0.6.8 → solverforge-0.6.9}/src/runtime/list_slots.rs +0 -0
  65. {solverforge-0.6.8 → solverforge-0.6.9}/src/runtime/mod.rs +0 -0
  66. {solverforge-0.6.8 → solverforge-0.6.9}/src/runtime/scalar_slots.rs +0 -0
  67. {solverforge-0.6.8 → solverforge-0.6.9}/src/schema/compiled.rs +0 -0
  68. {solverforge-0.6.8 → solverforge-0.6.9}/src/schema/mod.rs +0 -0
  69. {solverforge-0.6.8 → solverforge-0.6.9}/src/schema/python.rs +0 -0
  70. {solverforge-0.6.8 → solverforge-0.6.9}/src/schema/runtime_plan.rs +0 -0
  71. {solverforge-0.6.8 → solverforge-0.6.9}/src/schema/validate.rs +0 -0
  72. {solverforge-0.6.8 → solverforge-0.6.9}/src/score.rs +0 -0
  73. {solverforge-0.6.8 → solverforge-0.6.9}/src/solver/api.rs +0 -0
  74. {solverforge-0.6.8 → solverforge-0.6.9}/src/solver/console.rs +0 -0
  75. {solverforge-0.6.8 → solverforge-0.6.9}/src/solver/mod.rs +0 -0
  76. {solverforge-0.6.8 → solverforge-0.6.9}/src/solver/progress.rs +0 -0
  77. {solverforge-0.6.8 → solverforge-0.6.9}/src/solver/result.rs +0 -0
  78. {solverforge-0.6.8 → solverforge-0.6.9}/src/solver/solvable.rs +0 -0
  79. {solverforge-0.6.8 → solverforge-0.6.9}/src/state/callback_view.rs +0 -0
  80. {solverforge-0.6.8 → solverforge-0.6.9}/src/state/clone.rs +0 -0
  81. {solverforge-0.6.8 → solverforge-0.6.9}/src/state/mod.rs +0 -0
  82. {solverforge-0.6.8 → solverforge-0.6.9}/src/state/variables.rs +0 -0
  83. {solverforge-0.6.8 → solverforge-0.6.9}/src/ui.rs +0 -0
  84. {solverforge-0.6.8 → solverforge-0.6.9}/src/value.rs +0 -0
@@ -644,7 +644,7 @@ dependencies = [
644
644
 
645
645
  [[package]]
646
646
  name = "solverforge_py"
647
- version = "0.6.8"
647
+ version = "0.6.9"
648
648
  dependencies = [
649
649
  "indexmap",
650
650
  "once_cell",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "solverforge_py"
3
- version = "0.6.8"
3
+ version = "0.6.9"
4
4
  edition = "2021"
5
5
  license = "Apache-2.0"
6
6
  rust-version = "1.95"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solverforge
3
- Version: 0.6.8
3
+ Version: 0.6.9
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Science/Research
@@ -51,12 +51,12 @@ requirements, and other APIs that are not part of this package.
51
51
 
52
52
  ## Installation
53
53
 
54
- The package version prepared by this checkout is `solverforge` `0.6.8`, targeting
54
+ The package version prepared by this checkout is `solverforge` `0.6.9`, targeting
55
55
  the published and registry-locked SolverForge Rust `0.19.7` dependency base.
56
56
  Install the matching package after its Python release with:
57
57
 
58
58
  ```sh
59
- python3.14 -m pip install "solverforge==0.6.8"
59
+ python3.14 -m pip install "solverforge==0.6.9"
60
60
  ```
61
61
 
62
62
  The installable wheel contains the core `solverforge` package, native extension,
@@ -76,12 +76,12 @@ make deliveries-run PORT=7861
76
76
  ```
77
77
 
78
78
  The same source-checkout examples can be run against the matching published
79
- package once `0.6.8` is released, without `PYTHONPATH` or an editable checkout:
79
+ package once `0.6.9` is released, without `PYTHONPATH` or an editable checkout:
80
80
 
81
81
  ```sh
82
82
  python3.14 -m venv .venv-examples
83
83
  . .venv-examples/bin/activate
84
- python -m pip install "solverforge[examples]==0.6.8"
84
+ python -m pip install "solverforge[examples]==0.6.9"
85
85
  python examples/nqueens.py
86
86
  python -m examples.solverforge_hospital
87
87
  python -m examples.solverforge_deliveries
@@ -104,16 +104,19 @@ from solverforge import (
104
104
  Solver,
105
105
  constraint_provider,
106
106
  planning_entity,
107
+ planning_pin,
107
108
  planning_solution,
108
109
  planning_variable,
109
110
  )
110
111
 
111
112
  @planning_entity
112
113
  class Shift:
114
+ pinned = planning_pin()
113
115
  nurse = planning_variable(value_range_provider="nurses", allows_unassigned=True)
114
116
 
115
117
  def __init__(self, required: bool = True, nurse: int | None = None) -> None:
116
118
  self.required = required
119
+ self.pinned = False
117
120
  self.nurse = nurse
118
121
 
119
122
  @constraint_provider
@@ -243,11 +246,21 @@ Run `make help` for focused targets such as `make test-hospital`,
243
246
  per-query Python callbacks. Provider-backed value ranges are imported once per
244
247
  variable and shared across rows in Rust-owned state. Row candidate callbacks
245
248
  remain row-specific and define move legality throughout construction and local
246
- search, including assignment-group swaps and rematches. `pinning=True` is
247
- rejected with `ModelValidationError` rather than ignored: SolverForge pins a
248
- whole planning entity through an entity-level descriptor predicate, and no
249
- public bridge seam exposes entity pinning to the Python binding yet. Pin an
250
- input value by restricting `candidate_values` instead.
249
+ search, including assignment-group swaps and rematches.
250
+ - `planning_pin()` declares the entity attribute that holds an entity's pinned
251
+ flag. The declared attribute must hold a `bool` on every instance; an unset or
252
+ non-`bool` value is rejected at import rather than read as "not pinned". A
253
+ pinned entity keeps its input planning state: its genuine scalar value and the
254
+ elements its list variable owns survive construction, local search,
255
+ ruin/recreate, and exhaustive search, while every unpinned entity in the same
256
+ solve is still constructed and searched. Pinning preserves input state, it does
257
+ not exempt a row from the mandatory-completion gate: a pinned required scalar
258
+ that nothing assigned fails the solve, and a pinned `allows_unassigned` scalar
259
+ stays unassigned. It is a declared field, not a callback — there is no dynamic
260
+ `is_pinned()` hook, because the compiled runtime reads a resolved per-row flag.
261
+ The variable-level `planning_variable(pinning=True)` flag stays rejected with
262
+ `ModelValidationError`: SolverForge pins whole entities, and that flag names a
263
+ route the framework does not have.
251
264
  - `scalar_assignment_group(...)` declares assignment-aware scalar groups for
252
265
  grouped scalar local search and assignment-group construction. Group metadata
253
266
  covers required entities, capacity keys, assignment rules, ordering callbacks,
@@ -20,12 +20,12 @@ requirements, and other APIs that are not part of this package.
20
20
 
21
21
  ## Installation
22
22
 
23
- The package version prepared by this checkout is `solverforge` `0.6.8`, targeting
23
+ The package version prepared by this checkout is `solverforge` `0.6.9`, targeting
24
24
  the published and registry-locked SolverForge Rust `0.19.7` dependency base.
25
25
  Install the matching package after its Python release with:
26
26
 
27
27
  ```sh
28
- python3.14 -m pip install "solverforge==0.6.8"
28
+ python3.14 -m pip install "solverforge==0.6.9"
29
29
  ```
30
30
 
31
31
  The installable wheel contains the core `solverforge` package, native extension,
@@ -45,12 +45,12 @@ make deliveries-run PORT=7861
45
45
  ```
46
46
 
47
47
  The same source-checkout examples can be run against the matching published
48
- package once `0.6.8` is released, without `PYTHONPATH` or an editable checkout:
48
+ package once `0.6.9` is released, without `PYTHONPATH` or an editable checkout:
49
49
 
50
50
  ```sh
51
51
  python3.14 -m venv .venv-examples
52
52
  . .venv-examples/bin/activate
53
- python -m pip install "solverforge[examples]==0.6.8"
53
+ python -m pip install "solverforge[examples]==0.6.9"
54
54
  python examples/nqueens.py
55
55
  python -m examples.solverforge_hospital
56
56
  python -m examples.solverforge_deliveries
@@ -73,16 +73,19 @@ from solverforge import (
73
73
  Solver,
74
74
  constraint_provider,
75
75
  planning_entity,
76
+ planning_pin,
76
77
  planning_solution,
77
78
  planning_variable,
78
79
  )
79
80
 
80
81
  @planning_entity
81
82
  class Shift:
83
+ pinned = planning_pin()
82
84
  nurse = planning_variable(value_range_provider="nurses", allows_unassigned=True)
83
85
 
84
86
  def __init__(self, required: bool = True, nurse: int | None = None) -> None:
85
87
  self.required = required
88
+ self.pinned = False
86
89
  self.nurse = nurse
87
90
 
88
91
  @constraint_provider
@@ -212,11 +215,21 @@ Run `make help` for focused targets such as `make test-hospital`,
212
215
  per-query Python callbacks. Provider-backed value ranges are imported once per
213
216
  variable and shared across rows in Rust-owned state. Row candidate callbacks
214
217
  remain row-specific and define move legality throughout construction and local
215
- search, including assignment-group swaps and rematches. `pinning=True` is
216
- rejected with `ModelValidationError` rather than ignored: SolverForge pins a
217
- whole planning entity through an entity-level descriptor predicate, and no
218
- public bridge seam exposes entity pinning to the Python binding yet. Pin an
219
- input value by restricting `candidate_values` instead.
218
+ search, including assignment-group swaps and rematches.
219
+ - `planning_pin()` declares the entity attribute that holds an entity's pinned
220
+ flag. The declared attribute must hold a `bool` on every instance; an unset or
221
+ non-`bool` value is rejected at import rather than read as "not pinned". A
222
+ pinned entity keeps its input planning state: its genuine scalar value and the
223
+ elements its list variable owns survive construction, local search,
224
+ ruin/recreate, and exhaustive search, while every unpinned entity in the same
225
+ solve is still constructed and searched. Pinning preserves input state, it does
226
+ not exempt a row from the mandatory-completion gate: a pinned required scalar
227
+ that nothing assigned fails the solve, and a pinned `allows_unassigned` scalar
228
+ stays unassigned. It is a declared field, not a callback — there is no dynamic
229
+ `is_pinned()` hook, because the compiled runtime reads a resolved per-row flag.
230
+ The variable-level `planning_variable(pinning=True)` flag stays rejected with
231
+ `ModelValidationError`: SolverForge pins whole entities, and that flag names a
232
+ route the framework does not have.
220
233
  - `scalar_assignment_group(...)` declares assignment-aware scalar groups for
221
234
  grouped scalar local search and assignment-group construction. Group metadata
222
235
  covers required entities, capacity keys, assignment rules, ordering callbacks,
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "solverforge"
7
- version = "0.6.8"
7
+ version = "0.6.9"
8
8
  description = "Dynamic Python bindings for the SolverForge constraint solver."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -28,6 +28,7 @@ from .fields import (
28
28
  SolutionField,
29
29
  planning_id,
30
30
  planning_list_variable,
31
+ planning_pin,
31
32
  planning_variable,
32
33
  )
33
34
  from .groups import (
@@ -40,7 +41,7 @@ from .manager import JobHandle, SolverManager
40
41
  from .score import HardMediumSoftScore, HardSoftDecimalScore, HardSoftScore, SoftScore
41
42
  from .solver import Solver
42
43
 
43
- __version__ = "0.6.8"
44
+ __version__ = "0.6.9"
44
45
 
45
46
  __all__ = [
46
47
  "CallbackError",
@@ -78,6 +79,7 @@ __all__ = [
78
79
  "planning_entity",
79
80
  "planning_id",
80
81
  "planning_list_variable",
82
+ "planning_pin",
81
83
  "planning_solution",
82
84
  "planning_variable",
83
85
  "problem_fact",
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from collections.abc import Callable
4
4
  from typing import Any, TypeVar
5
5
 
6
+ from .errors import ModelValidationError
6
7
  from .fields import (
7
8
  CapacityRouteFeasibility,
8
9
  EntityCallback,
@@ -22,8 +23,11 @@ T = TypeVar("T", bound=type[object])
22
23
 
23
24
  def _collect_fields(cls: type[object]) -> list[dict[str, object]]:
24
25
  fields: list[dict[str, object]] = []
26
+ pin_fields: list[str] = []
25
27
  for name, value in vars(cls).items():
26
28
  if isinstance(value, PlanningField):
29
+ if value.metadata.kind == "planning_pin":
30
+ pin_fields.append(name)
27
31
  fields.append(
28
32
  {
29
33
  "name": name,
@@ -51,6 +55,9 @@ def _collect_fields(cls: type[object]) -> list[dict[str, object]]:
51
55
  "list_metadata": _list_metadata(value.metadata.list_metadata),
52
56
  }
53
57
  )
58
+ if len(pin_fields) > 1:
59
+ msg = f"{cls.__name__} declares more than one planning_pin field"
60
+ raise ModelValidationError(msg)
54
61
  return fields
55
62
 
56
63
 
@@ -133,12 +140,19 @@ def planning_entity(cls: T) -> T:
133
140
 
134
141
 
135
142
  def problem_fact(cls: T) -> T:
143
+ fields = _collect_fields(cls)
144
+ if any(field["kind"] == "planning_pin" for field in fields):
145
+ msg = (
146
+ f"{cls.__name__} is a problem fact; planning_pin is only valid on a "
147
+ "planning entity"
148
+ )
149
+ raise ModelValidationError(msg)
136
150
  setattr(
137
151
  cls,
138
152
  "__solverforge_fact__",
139
153
  {
140
154
  "type_name": cls.__name__,
141
- "fields": _collect_fields(cls),
155
+ "fields": fields,
142
156
  },
143
157
  )
144
158
  return cls
@@ -181,6 +181,17 @@ def planning_id() -> PlanningField:
181
181
  return PlanningField(FieldMetadata(kind="planning_id"))
182
182
 
183
183
 
184
+ def planning_pin() -> PlanningField:
185
+ """Declares the entity attribute that holds this entity's pinned flag.
186
+
187
+ The declared attribute must hold a ``bool`` on every instance. A pinned
188
+ entity keeps its input planning state: its genuine scalar value and its
189
+ list ownership survive construction and search.
190
+ """
191
+
192
+ return PlanningField(FieldMetadata(kind="planning_pin"))
193
+
194
+
184
195
  def planning_variable(
185
196
  *,
186
197
  value_range_provider: str,
@@ -195,9 +206,8 @@ def planning_variable(
195
206
  if pinning:
196
207
  msg = (
197
208
  "planning_variable(pinning=True) is not supported: SolverForge pins a whole "
198
- "planning entity through an entity-level descriptor predicate, and no public "
199
- "bridge seam exposes entity pinning to the Python binding yet. Pin an input "
200
- "value by restricting candidate_values instead."
209
+ "planning entity, not one variable. Declare an entity pin field with "
210
+ "planning_pin() instead."
201
211
  )
202
212
  raise ModelValidationError(msg)
203
213
  value_candidates = _resolve_metadata_source(
@@ -1,14 +1,26 @@
1
- use std::any::TypeId;
1
+ use std::any::{Any, TypeId};
2
2
 
3
3
  use solverforge_bridge::{EntityClassId, VariableId};
4
4
  use solverforge_core::domain::{EntityDescriptor, SolutionDescriptor, VariableDescriptor};
5
5
 
6
6
  use crate::descriptor::extractor::DynamicEntityExtractor;
7
7
  use crate::intern::intern;
8
+ use crate::state::entity_table::DynamicEntityRow;
8
9
  use crate::state::PyDynamicSolution;
9
10
 
10
11
  use super::DynamicSchema;
11
12
 
13
+ /// The one entity pin predicate for every compiled Python schema.
14
+ ///
15
+ /// The declaration is resolved per row at import, so this stays a capture-free
16
+ /// function over Rust-owned state: no thread-local slot lookup, no Python
17
+ /// callback, and no per-candidate reinterpretation of the declared attribute.
18
+ fn pinned_row_predicate(entity: &dyn Any) -> bool {
19
+ entity
20
+ .downcast_ref::<DynamicEntityRow>()
21
+ .is_some_and(|row| row.pinned)
22
+ }
23
+
12
24
  pub fn solution_descriptor(schema: &DynamicSchema) -> SolutionDescriptor {
13
25
  let mut descriptor = SolutionDescriptor::new(
14
26
  intern(schema.solution_type.clone()),
@@ -37,6 +49,11 @@ pub fn solution_descriptor(schema: &DynamicSchema) -> SolutionDescriptor {
37
49
  .with_logical_id(VariableId(variable_index));
38
50
  entity_descriptor = entity_descriptor.with_variable(descriptor_variable);
39
51
  }
52
+ if let Some(pin_field) = entity.pin_field.as_deref() {
53
+ entity_descriptor = entity_descriptor
54
+ .with_pin_field(intern(pin_field.to_string()))
55
+ .with_pin_predicate(pinned_row_predicate);
56
+ }
40
57
  descriptor = descriptor.with_entity(entity_descriptor);
41
58
  }
42
59
  descriptor
@@ -34,9 +34,20 @@ pub fn parse_schema(schema: &Bound<'_, PyDict>) -> PyResult<DynamicSchema> {
34
34
  .ok_or_else(|| py_err(format!("entity `{type_name}` is missing `fields`")))?;
35
35
  let fields = fields_any.cast::<PyList>()?;
36
36
  let mut variables = Vec::new();
37
+ let mut pin_field: Option<String> = None;
37
38
  for field_any in fields.iter() {
38
39
  let field = field_any.cast::<PyDict>()?;
39
40
  let kind = required_str(field, "kind")?;
41
+ if kind == "planning_pin" {
42
+ let name = required_non_empty_str(field, "name")?;
43
+ if pin_field.is_some() {
44
+ return Err(py_err(format!(
45
+ "entity `{type_name}` declares more than one planning_pin field"
46
+ )));
47
+ }
48
+ pin_field = Some(name);
49
+ continue;
50
+ }
40
51
  if kind == "planning_variable" || kind == "planning_list_variable" {
41
52
  let list_metadata = if kind == "planning_list_variable" {
42
53
  Some(parse_list_metadata(field)?)
@@ -98,10 +109,19 @@ pub fn parse_schema(schema: &Bound<'_, PyDict>) -> PyResult<DynamicSchema> {
98
109
  });
99
110
  }
100
111
  }
112
+ if let Some(pin_field) = pin_field.as_deref() {
113
+ if variables.iter().any(|variable| variable.name == pin_field) {
114
+ return Err(py_err(format!(
115
+ "entity `{type_name}` declares `{pin_field}` as both a planning \
116
+ variable and a planning_pin field"
117
+ )));
118
+ }
119
+ }
101
120
  entities.push(EntitySchema {
102
121
  type_name,
103
122
  collection,
104
123
  variables,
124
+ pin_field,
105
125
  });
106
126
  }
107
127
  let facts = parse_facts(schema)?;
@@ -7,6 +7,10 @@ pub struct EntitySchema {
7
7
  pub type_name: String,
8
8
  pub collection: String,
9
9
  pub variables: Vec<VariableSchema>,
10
+ /// The declared entity attribute holding this entity's pinned flag. It is
11
+ /// read once per instance at import and enforced by the compiled
12
+ /// SolverForge runtime, never by wrapper-owned phase code.
13
+ pub pin_field: Option<String>,
10
14
  }
11
15
 
12
16
  #[derive(Debug)]
@@ -76,6 +76,11 @@ pub struct DynamicEntityRow {
76
76
  pub scalar_values: Vec<Option<usize>>,
77
77
  pub candidate_values: Vec<Option<Arc<[usize]>>>,
78
78
  pub list_values: Vec<Option<Vec<usize>>>,
79
+ /// The entity's declared pinned flag, resolved once at import. It is the
80
+ /// only input the compiled runtime reads through the descriptor's pin
81
+ /// predicate. It is a plain row field, so every copy of a row that clones
82
+ /// (candidate, snapshot, or detached solution) carries the resolved value.
83
+ pub pinned: bool,
79
84
  }
80
85
 
81
86
  impl DynamicEntityRow {
@@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet};
2
2
  use std::sync::Arc;
3
3
 
4
4
  use pyo3::prelude::*;
5
- use pyo3::types::{PyDict, PyInt, PyList, PyString};
5
+ use pyo3::types::{PyBool, PyDict, PyInt, PyList, PyString};
6
6
  use solverforge_config::SolverConfig;
7
7
 
8
8
  use crate::schema::runtime_plan::CompiledRuntimePlan;
@@ -116,6 +116,9 @@ pub fn import_solution(
116
116
  Some(&row_metadata_fields),
117
117
  )?;
118
118
  import_named_fields(&item, &extra_field_names, &mut row)?;
119
+ if let Some(pin_field) = entity.pin_field.as_deref() {
120
+ row.pinned = import_pin_value(&item, pin_field, &entity.type_name)?;
121
+ }
119
122
  rows.push(row);
120
123
  objects.push(item.unbind());
121
124
  }
@@ -1176,6 +1179,40 @@ fn import_named_fields(
1176
1179
  Ok(())
1177
1180
  }
1178
1181
 
1182
+ /// Reads one entity's declared pinned flag.
1183
+ ///
1184
+ /// The declaration is a contract on the instance, not a hint: an unset, missing,
1185
+ /// or non-`bool` value is an error rather than an implicit "not pinned", because
1186
+ /// a silently unpinned declaration would look identical to a working one while
1187
+ /// preserving nothing. The declared attribute is a `planning_pin()` descriptor,
1188
+ /// so an instance that never sets it reads back as `None` rather than raising.
1189
+ fn import_pin_value(item: &Bound<'_, PyAny>, pin_field: &str, type_name: &str) -> PyResult<bool> {
1190
+ let value = match item.getattr(pin_field) {
1191
+ Ok(value) => value,
1192
+ Err(cause) => {
1193
+ // Keep the original failure as the cause: a declared attribute that
1194
+ // exists but raises is a different problem from one that is absent,
1195
+ // and the operator needs the original traceback to tell them apart.
1196
+ let error = crate::error::py_err(format!(
1197
+ "pinned entity `{type_name}` has no readable `{pin_field}` attribute"
1198
+ ));
1199
+ error.set_cause(item.py(), Some(cause));
1200
+ return Err(error);
1201
+ }
1202
+ };
1203
+ if value.is_none() {
1204
+ return Err(crate::error::py_err(format!(
1205
+ "pinned entity `{type_name}` must set a bool `{pin_field}` attribute per instance"
1206
+ )));
1207
+ }
1208
+ if !value.is_instance_of::<PyBool>() {
1209
+ return Err(crate::error::py_err(format!(
1210
+ "pinned entity `{type_name}` attribute `{pin_field}` must be a bool"
1211
+ )));
1212
+ }
1213
+ value.extract::<bool>()
1214
+ }
1215
+
1179
1216
  fn is_read_only_property(
1180
1217
  item: &Bound<'_, PyAny>,
1181
1218
  name: &str,
@@ -741,6 +741,7 @@ mod tests {
741
741
  entities: vec![EntitySchema {
742
742
  type_name: "Route".to_string(),
743
743
  collection: "routes".to_string(),
744
+ pin_field: None,
744
745
  variables: vec![VariableSchema {
745
746
  name: "visits".to_string(),
746
747
  storage_name: "__solverforge_visits".to_string(),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes