solverforge 0.6.8__tar.gz → 0.6.10__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.10}/Cargo.lock +1 -1
  2. {solverforge-0.6.8 → solverforge-0.6.10}/Cargo.toml +1 -1
  3. {solverforge-0.6.8 → solverforge-0.6.10}/PKG-INFO +23 -10
  4. {solverforge-0.6.8 → solverforge-0.6.10}/README.md +22 -9
  5. {solverforge-0.6.8 → solverforge-0.6.10}/pyproject.toml +5 -1
  6. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/__init__.py +3 -1
  7. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/_native.pyi +2 -6
  8. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/config.py +1 -3
  9. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/constraints.py +8 -25
  10. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/decorators.py +15 -4
  11. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/errors.py +0 -1
  12. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/events.py +0 -1
  13. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/fields.py +20 -25
  14. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/groups.py +4 -13
  15. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/model.py +6 -9
  16. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/solver.py +1 -3
  17. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/typing.py +0 -1
  18. {solverforge-0.6.8 → solverforge-0.6.10}/src/schema/build.rs +18 -1
  19. {solverforge-0.6.8 → solverforge-0.6.10}/src/schema/parse.rs +20 -0
  20. {solverforge-0.6.8 → solverforge-0.6.10}/src/schema/types.rs +4 -0
  21. {solverforge-0.6.8 → solverforge-0.6.10}/src/state/entity_table.rs +5 -0
  22. {solverforge-0.6.8 → solverforge-0.6.10}/src/state/marshal.rs +38 -1
  23. {solverforge-0.6.8 → solverforge-0.6.10}/src/state/solution.rs +1 -0
  24. {solverforge-0.6.8 → solverforge-0.6.10}/LICENSE +0 -0
  25. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/console.py +0 -0
  26. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/joiner.py +0 -0
  27. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/manager.py +0 -0
  28. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/py.typed +0 -0
  29. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/score.py +0 -0
  30. {solverforge-0.6.8 → solverforge-0.6.10}/python/solverforge/ui.py +0 -0
  31. {solverforge-0.6.8 → solverforge-0.6.10}/rust-toolchain.toml +0 -0
  32. {solverforge-0.6.8 → solverforge-0.6.10}/src/bindings.rs +0 -0
  33. {solverforge-0.6.8 → solverforge-0.6.10}/src/callbacks/call.rs +0 -0
  34. {solverforge-0.6.8 → solverforge-0.6.10}/src/callbacks/mod.rs +0 -0
  35. {solverforge-0.6.8 → solverforge-0.6.10}/src/callbacks/registry.rs +0 -0
  36. {solverforge-0.6.8 → solverforge-0.6.10}/src/callbacks/threading.rs +0 -0
  37. {solverforge-0.6.8 → solverforge-0.6.10}/src/config.rs +0 -0
  38. {solverforge-0.6.8 → solverforge-0.6.10}/src/constraints/compiled.rs +0 -0
  39. {solverforge-0.6.8 → solverforge-0.6.10}/src/constraints/evaluate.rs +0 -0
  40. {solverforge-0.6.8 → solverforge-0.6.10}/src/constraints/incremental.rs +0 -0
  41. {solverforge-0.6.8 → solverforge-0.6.10}/src/constraints/list_precedence.rs +0 -0
  42. {solverforge-0.6.8 → solverforge-0.6.10}/src/constraints/matches.rs +0 -0
  43. {solverforge-0.6.8 → solverforge-0.6.10}/src/constraints/mod.rs +0 -0
  44. {solverforge-0.6.8 → solverforge-0.6.10}/src/constraints/state.rs +0 -0
  45. {solverforge-0.6.8 → solverforge-0.6.10}/src/constraints/stream_plan.rs +0 -0
  46. {solverforge-0.6.8 → solverforge-0.6.10}/src/descriptor/extractor.rs +0 -0
  47. {solverforge-0.6.8 → solverforge-0.6.10}/src/descriptor/mod.rs +0 -0
  48. {solverforge-0.6.8 → solverforge-0.6.10}/src/descriptor/variable.rs +0 -0
  49. {solverforge-0.6.8 → solverforge-0.6.10}/src/error.rs +0 -0
  50. {solverforge-0.6.8 → solverforge-0.6.10}/src/intern.rs +0 -0
  51. {solverforge-0.6.8 → solverforge-0.6.10}/src/lib.rs +0 -0
  52. {solverforge-0.6.8 → solverforge-0.6.10}/src/manager/candidate_trace.rs +0 -0
  53. {solverforge-0.6.8 → solverforge-0.6.10}/src/manager/events.rs +0 -0
  54. {solverforge-0.6.8 → solverforge-0.6.10}/src/manager/jobs.rs +0 -0
  55. {solverforge-0.6.8 → solverforge-0.6.10}/src/manager/mod.rs +0 -0
  56. {solverforge-0.6.8 → solverforge-0.6.10}/src/manager/provenance.rs +0 -0
  57. {solverforge-0.6.8 → solverforge-0.6.10}/src/proxy/entity.rs +0 -0
  58. {solverforge-0.6.8 → solverforge-0.6.10}/src/proxy/list.rs +0 -0
  59. {solverforge-0.6.8 → solverforge-0.6.10}/src/proxy/mod.rs +0 -0
  60. {solverforge-0.6.8 → solverforge-0.6.10}/src/proxy/solution.rs +0 -0
  61. {solverforge-0.6.8 → solverforge-0.6.10}/src/runtime/candidate_metric.rs +0 -0
  62. {solverforge-0.6.8 → solverforge-0.6.10}/src/runtime/dynamic_assignment_group.rs +0 -0
  63. {solverforge-0.6.8 → solverforge-0.6.10}/src/runtime/dynamic_provider.rs +0 -0
  64. {solverforge-0.6.8 → solverforge-0.6.10}/src/runtime/list_slots.rs +0 -0
  65. {solverforge-0.6.8 → solverforge-0.6.10}/src/runtime/mod.rs +0 -0
  66. {solverforge-0.6.8 → solverforge-0.6.10}/src/runtime/scalar_slots.rs +0 -0
  67. {solverforge-0.6.8 → solverforge-0.6.10}/src/schema/compiled.rs +0 -0
  68. {solverforge-0.6.8 → solverforge-0.6.10}/src/schema/mod.rs +0 -0
  69. {solverforge-0.6.8 → solverforge-0.6.10}/src/schema/python.rs +0 -0
  70. {solverforge-0.6.8 → solverforge-0.6.10}/src/schema/runtime_plan.rs +0 -0
  71. {solverforge-0.6.8 → solverforge-0.6.10}/src/schema/validate.rs +0 -0
  72. {solverforge-0.6.8 → solverforge-0.6.10}/src/score.rs +0 -0
  73. {solverforge-0.6.8 → solverforge-0.6.10}/src/solver/api.rs +0 -0
  74. {solverforge-0.6.8 → solverforge-0.6.10}/src/solver/console.rs +0 -0
  75. {solverforge-0.6.8 → solverforge-0.6.10}/src/solver/mod.rs +0 -0
  76. {solverforge-0.6.8 → solverforge-0.6.10}/src/solver/progress.rs +0 -0
  77. {solverforge-0.6.8 → solverforge-0.6.10}/src/solver/result.rs +0 -0
  78. {solverforge-0.6.8 → solverforge-0.6.10}/src/solver/solvable.rs +0 -0
  79. {solverforge-0.6.8 → solverforge-0.6.10}/src/state/callback_view.rs +0 -0
  80. {solverforge-0.6.8 → solverforge-0.6.10}/src/state/clone.rs +0 -0
  81. {solverforge-0.6.8 → solverforge-0.6.10}/src/state/mod.rs +0 -0
  82. {solverforge-0.6.8 → solverforge-0.6.10}/src/state/variables.rs +0 -0
  83. {solverforge-0.6.8 → solverforge-0.6.10}/src/ui.rs +0 -0
  84. {solverforge-0.6.8 → solverforge-0.6.10}/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.10"
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.10"
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.10
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.10`, 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.10"
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.10` 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.10"
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.10`, 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.10"
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.10` 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.10"
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.10"
8
8
  description = "Dynamic Python bindings for the SolverForge constraint solver."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -51,6 +51,10 @@ strip = true
51
51
  testpaths = ["tests/python"]
52
52
  pythonpath = ["python"]
53
53
 
54
+ [tool.black]
55
+ line-length = 100
56
+ target-version = ["py314"]
57
+
54
58
  [tool.ruff]
55
59
  line-length = 100
56
60
  target-version = "py314"
@@ -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.10"
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",
@@ -38,18 +38,14 @@ class SolverManager:
38
38
  def _preflight_qualified_candidate_trace_provenance(
39
39
  self,
40
40
  *,
41
- qualified_candidate_trace_provenance: (
42
- QualifiedCandidateTraceProvenance | None
43
- ) = None,
41
+ qualified_candidate_trace_provenance: QualifiedCandidateTraceProvenance | None = None,
44
42
  ) -> None: ...
45
43
  def solve(
46
44
  self,
47
45
  solution: object,
48
46
  schema: CompiledSchema,
49
47
  *,
50
- qualified_candidate_trace_provenance: (
51
- QualifiedCandidateTraceProvenance | None
52
- ) = None,
48
+ qualified_candidate_trace_provenance: QualifiedCandidateTraceProvenance | None = None,
53
49
  ) -> int: ...
54
50
  def get_status(self, job_id: int) -> dict[str, object]: ...
55
51
  def telemetry_detail(self, job_id: int) -> dict[str, object]: ...
@@ -162,9 +162,7 @@ def _normalize_phases(phases: list[dict[str, Any]]) -> list[dict[str, Any]]:
162
162
  dict(phase_copy["termination"])
163
163
  ).to_dict()
164
164
  if "child_phases" in phase_copy:
165
- phase_copy["child_phases"] = _normalize_phases(
166
- list(phase_copy["child_phases"])
167
- )
165
+ phase_copy["child_phases"] = _normalize_phases(list(phase_copy["child_phases"]))
168
166
  normalized.append(phase_copy)
169
167
  return normalized
170
168
 
@@ -163,10 +163,7 @@ class ListPrecedenceMakespanConstraintStream:
163
163
  if plan.precedence_duration is None and plan.precedence_duration_field is None:
164
164
  msg = f"{self.entity_type.__name__}.{self.variable_name} requires precedence_duration"
165
165
  raise TypeError(msg)
166
- if (
167
- plan.precedence_successors is None
168
- and plan.precedence_successors_field is None
169
- ):
166
+ if plan.precedence_successors is None and plan.precedence_successors_field is None:
170
167
  msg = f"{self.entity_type.__name__}.{self.variable_name} requires precedence_successors"
171
168
  raise TypeError(msg)
172
169
  if plan.element_owner is None and plan.element_owner_field is None:
@@ -382,9 +379,7 @@ class ConstraintFactory:
382
379
  self.score_family = score_family
383
380
 
384
381
  def for_each(self, entity_type: type[object]) -> UniConstraintStream:
385
- return UniConstraintStream(
386
- entity_type=entity_type, score_family=self.score_family
387
- )
382
+ return UniConstraintStream(entity_type=entity_type, score_family=self.score_family)
388
383
 
389
384
  def for_each_unassigned_element(
390
385
  self,
@@ -409,14 +404,10 @@ class ConstraintFactory:
409
404
  )
410
405
 
411
406
  def join(self, *_args: Any, **_kwargs: Any) -> None:
412
- raise NotImplementedError(
413
- "dynamic join is implemented in the native stream planner"
414
- )
407
+ raise NotImplementedError("dynamic join is implemented in the native stream planner")
415
408
 
416
409
  def if_exists(self, *_args: Any, **_kwargs: Any) -> None:
417
- raise NotImplementedError(
418
- "dynamic if_exists is implemented in the native stream planner"
419
- )
410
+ raise NotImplementedError("dynamic if_exists is implemented in the native stream planner")
420
411
 
421
412
  def if_not_exists(self, *_args: Any, **_kwargs: Any) -> None:
422
413
  raise NotImplementedError(
@@ -424,14 +415,10 @@ class ConstraintFactory:
424
415
  )
425
416
 
426
417
  def group_by(self, *_args: Any, **_kwargs: Any) -> None:
427
- raise NotImplementedError(
428
- "dynamic group_by is implemented in the native stream planner"
429
- )
418
+ raise NotImplementedError("dynamic group_by is implemented in the native stream planner")
430
419
 
431
420
  def flattened(self, *_args: Any, **_kwargs: Any) -> None:
432
- raise NotImplementedError(
433
- "dynamic flattened is implemented in the native stream planner"
434
- )
421
+ raise NotImplementedError("dynamic flattened is implemented in the native stream planner")
435
422
 
436
423
 
437
424
  def joiner_to_native(joiner: object) -> object:
@@ -462,9 +449,7 @@ def collector_to_native(collector: object) -> object:
462
449
  return collector
463
450
 
464
451
 
465
- def _list_variable_element_collection(
466
- entity_type: type[object], variable_name: str
467
- ) -> str:
452
+ def _list_variable_element_collection(entity_type: type[object], variable_name: str) -> str:
468
453
  field_info = _list_variable_metadata(entity_type, variable_name)
469
454
  collection = field_info.get("element_collection")
470
455
  if isinstance(collection, str) and collection:
@@ -473,9 +458,7 @@ def _list_variable_element_collection(
473
458
  raise TypeError(msg)
474
459
 
475
460
 
476
- def _list_variable_metadata(
477
- entity_type: type[object], variable_name: str
478
- ) -> dict[str, object]:
461
+ def _list_variable_metadata(entity_type: type[object], variable_name: str) -> dict[str, object]:
479
462
  metadata = getattr(entity_type, "__solverforge_entity__", None)
480
463
  if not isinstance(metadata, dict):
481
464
  msg = f"{entity_type.__name__} is not marked with @planning_entity"
@@ -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,18 @@ 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 " "planning entity"
147
+ )
148
+ raise ModelValidationError(msg)
136
149
  setattr(
137
150
  cls,
138
151
  "__solverforge_fact__",
139
152
  {
140
153
  "type_name": cls.__name__,
141
- "fields": _collect_fields(cls),
154
+ "fields": fields,
142
155
  },
143
156
  )
144
157
  return cls
@@ -206,9 +219,7 @@ def planning_solution(
206
219
  shadow_variable_updates: dict[str, Any] | list[dict[str, Any]] | None = None,
207
220
  ) -> Callable[[T], T]:
208
221
  def decorate(cls: T) -> T:
209
- updates = (
210
- shadow_updates if shadow_updates is not None else shadow_variable_updates
211
- )
222
+ updates = shadow_updates if shadow_updates is not None else shadow_variable_updates
212
223
  if updates is None:
213
224
  update_list: list[dict[str, Any]] = []
214
225
  elif isinstance(updates, dict):
@@ -12,4 +12,3 @@ class CallbackError(SolverForgeError):
12
12
 
13
13
  class NativeBridgeError(SolverForgeError):
14
14
  """Raised when the PyO3 bridge reports a native-side failure."""
15
-
@@ -10,4 +10,3 @@ class SolverEvent:
10
10
  lifecycle_state: str
11
11
  snapshot_revision: int | None = None
12
12
  error: str | None = None
13
-
@@ -59,9 +59,7 @@ class SolutionCallback:
59
59
  raise TypeError("SolutionCallback callback must be callable")
60
60
 
61
61
 
62
- ListValueSource: TypeAlias = (
63
- RowField | SolutionField | EntityCallback | SolutionCallback
64
- )
62
+ ListValueSource: TypeAlias = RowField | SolutionField | EntityCallback | SolutionCallback
65
63
  ListFeasibilityFieldSource: TypeAlias = RowField | SolutionField
66
64
 
67
65
 
@@ -74,14 +72,10 @@ class CapacityRouteFeasibility:
74
72
 
75
73
  def __post_init__(self) -> None:
76
74
  if self.capacity is None or self.demand is None:
77
- raise TypeError(
78
- "CapacityRouteFeasibility requires capacity and demand sources"
79
- )
75
+ raise TypeError("CapacityRouteFeasibility requires capacity and demand sources")
80
76
 
81
77
 
82
- ListFeasibilitySource: TypeAlias = (
83
- EntityCallback | SolutionCallback | CapacityRouteFeasibility
84
- )
78
+ ListFeasibilitySource: TypeAlias = EntityCallback | SolutionCallback | CapacityRouteFeasibility
85
79
 
86
80
 
87
81
  @dataclass(frozen=True)
@@ -94,9 +88,7 @@ class ListRouteHooks:
94
88
 
95
89
  def __post_init__(self) -> None:
96
90
  if self.depot is None or self.distance is None or self.feasible is None:
97
- raise TypeError(
98
- "ListRouteHooks requires depot, distance, and feasible sources"
99
- )
91
+ raise TypeError("ListRouteHooks requires depot, distance, and feasible sources")
100
92
 
101
93
 
102
94
  @dataclass(frozen=True)
@@ -181,6 +173,17 @@ def planning_id() -> PlanningField:
181
173
  return PlanningField(FieldMetadata(kind="planning_id"))
182
174
 
183
175
 
176
+ def planning_pin() -> PlanningField:
177
+ """Declares the entity attribute that holds this entity's pinned flag.
178
+
179
+ The declared attribute must hold a ``bool`` on every instance. A pinned
180
+ entity keeps its input planning state: its genuine scalar value and its
181
+ list ownership survive construction and search.
182
+ """
183
+
184
+ return PlanningField(FieldMetadata(kind="planning_pin"))
185
+
186
+
184
187
  def planning_variable(
185
188
  *,
186
189
  value_range_provider: str,
@@ -195,9 +198,8 @@ def planning_variable(
195
198
  if pinning:
196
199
  msg = (
197
200
  "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."
201
+ "planning entity, not one variable. Declare an entity pin field with "
202
+ "planning_pin() instead."
201
203
  )
202
204
  raise ModelValidationError(msg)
203
205
  value_candidates = _resolve_metadata_source(
@@ -393,9 +395,7 @@ def _resolve_list_value_source(
393
395
  solution_arity,
394
396
  )
395
397
  return source
396
- raise TypeError(
397
- f"{name} must be RowField, SolutionField, EntityCallback, or SolutionCallback"
398
- )
398
+ raise TypeError(f"{name} must be RowField, SolutionField, EntityCallback, or SolutionCallback")
399
399
 
400
400
 
401
401
  def _resolve_required_list_value_source(
@@ -445,9 +445,7 @@ def _resolve_list_feasibility_source(
445
445
  solution_arity,
446
446
  )
447
447
  return source
448
- raise TypeError(
449
- f"{name} must be EntityCallback, SolutionCallback, or CapacityRouteFeasibility"
450
- )
448
+ raise TypeError(f"{name} must be EntityCallback, SolutionCallback, or CapacityRouteFeasibility")
451
449
 
452
450
 
453
451
  def _resolve_list_feasibility_field_source(
@@ -491,10 +489,7 @@ def _callback_accepts_arity(callback: Callable[..., object], arity: int) -> bool
491
489
  required_positional_count += 1
492
490
  elif parameter.kind is Parameter.VAR_POSITIONAL:
493
491
  accepts_extra_positionals = True
494
- elif (
495
- parameter.kind is Parameter.KEYWORD_ONLY
496
- and parameter.default is Parameter.empty
497
- ):
492
+ elif parameter.kind is Parameter.KEYWORD_ONLY and parameter.default is Parameter.empty:
498
493
  return False
499
494
 
500
495
  if arity < required_positional_count:
@@ -89,17 +89,10 @@ class ScalarAssignmentGroup:
89
89
  ),
90
90
  ):
91
91
  if callback is not None and field is not None:
92
- raise TypeError(
93
- f"{callback_name} and {field_name} cannot both be configured"
94
- )
92
+ raise TypeError(f"{callback_name} and {field_name} cannot both be configured")
95
93
  if field is not None and (not isinstance(field, str) or not field):
96
- raise TypeError(
97
- f"{field_name} must be a non-empty string when provided"
98
- )
99
- if (
100
- self.assignment_rule is not None
101
- and self.same_value_conflict_field is not None
102
- ):
94
+ raise TypeError(f"{field_name} must be a non-empty string when provided")
95
+ if self.assignment_rule is not None and self.same_value_conflict_field is not None:
103
96
  raise TypeError(
104
97
  "assignment_rule and same_value_conflict_field cannot both be configured"
105
98
  )
@@ -107,9 +100,7 @@ class ScalarAssignmentGroup:
107
100
  not isinstance(self.same_value_conflict_field, str)
108
101
  or not self.same_value_conflict_field
109
102
  ):
110
- raise TypeError(
111
- "same_value_conflict_field must be a non-empty string when provided"
112
- )
103
+ raise TypeError("same_value_conflict_field must be a non-empty string when provided")
113
104
 
114
105
  def to_native(self) -> dict[str, Any]:
115
106
  return {
@@ -90,9 +90,7 @@ def _schema_shape(value: object) -> tuple[SchemaShape, bool]:
90
90
 
91
91
  def shape(item: object, *, callback_state: bool = False) -> SchemaShape:
92
92
  nonlocal cacheable, next_marker
93
- recursive = isinstance(
94
- item, (dict, list, tuple, FunctionType, MethodType, partial)
95
- )
93
+ recursive = isinstance(item, (dict, list, tuple, FunctionType, MethodType, partial))
96
94
  item_id = id(item)
97
95
  if recursive:
98
96
  if item_id in active:
@@ -147,8 +145,7 @@ def _schema_shape(value: object) -> tuple[SchemaShape, bool]:
147
145
  for cell in (item.__closure__ or ())
148
146
  )
149
147
  defaults = tuple(
150
- shape(default, callback_state=True)
151
- for default in (item.__defaults__ or ())
148
+ shape(default, callback_state=True) for default in (item.__defaults__ or ())
152
149
  )
153
150
  keyword_defaults = tuple(
154
151
  (name, shape(default, callback_state=True))
@@ -241,7 +238,9 @@ def _scalar_groups(callbacks: object) -> list[dict[str, object]]:
241
238
  callback = cast(Callable[..., object], item)
242
239
  metadata = getattr(callback, "__solverforge_scalar_group__", None)
243
240
  if not isinstance(metadata, dict):
244
- msg = f"{callback!r} is not marked with @scalar_group and is not a ScalarAssignmentGroup"
241
+ msg = (
242
+ f"{callback!r} is not marked with @scalar_group and is not a ScalarAssignmentGroup"
243
+ )
245
244
  raise ModelValidationError(msg)
246
245
  name = metadata.get("name")
247
246
  if not isinstance(name, str) or not name:
@@ -262,9 +261,7 @@ def _conflict_repairs(callbacks: object) -> list[dict[str, object]]:
262
261
  if not isinstance(metadata, dict):
263
262
  msg = f"{callback!r} is not marked with @conflict_repair"
264
263
  raise ModelValidationError(msg)
265
- constraint_names = list(
266
- cast(Iterable[object], metadata.get("constraints") or [])
267
- )
264
+ constraint_names = list(cast(Iterable[object], metadata.get("constraints") or []))
268
265
  if not constraint_names or not all(
269
266
  isinstance(name, str) and name for name in constraint_names
270
267
  ):
@@ -9,9 +9,7 @@ from .model import _compiled_schema_for_solution
9
9
 
10
10
  class Solver:
11
11
  @staticmethod
12
- def solve(
13
- solution: object, config: SolverConfig | dict[str, Any] | None = None
14
- ) -> object:
12
+ def solve(solution: object, config: SolverConfig | dict[str, Any] | None = None) -> object:
15
13
  schema = _compiled_schema_for_solution(solution)
16
14
  return _native.solve(solution, schema, _resolve_config(config))
17
15
 
@@ -14,4 +14,3 @@ class PlanningEntityProtocol(Protocol):
14
14
 
15
15
  class PlanningSolutionProtocol(Protocol):
16
16
  score: object | None
17
-
@@ -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