solverforge 0.6.4__tar.gz → 0.6.6__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.4 → solverforge-0.6.6}/Cargo.lock +13 -13
  2. {solverforge-0.6.4 → solverforge-0.6.6}/Cargo.toml +8 -8
  3. {solverforge-0.6.4 → solverforge-0.6.6}/PKG-INFO +27 -21
  4. {solverforge-0.6.4 → solverforge-0.6.6}/README.md +26 -20
  5. {solverforge-0.6.4 → solverforge-0.6.6}/pyproject.toml +1 -1
  6. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/__init__.py +1 -1
  7. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/groups.py +21 -0
  8. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/model.py +10 -0
  9. {solverforge-0.6.4 → solverforge-0.6.6}/src/runtime/dynamic_assignment_group.rs +79 -1
  10. {solverforge-0.6.4 → solverforge-0.6.6}/src/runtime/scalar_slots.rs +7 -1
  11. {solverforge-0.6.4 → solverforge-0.6.6}/src/schema/parse.rs +1 -0
  12. {solverforge-0.6.4 → solverforge-0.6.6}/src/schema/types.rs +1 -0
  13. {solverforge-0.6.4 → solverforge-0.6.6}/src/schema/validate.rs +22 -0
  14. {solverforge-0.6.4 → solverforge-0.6.6}/src/state/marshal.rs +34 -0
  15. {solverforge-0.6.4 → solverforge-0.6.6}/src/state/solution.rs +10 -1
  16. {solverforge-0.6.4 → solverforge-0.6.6}/LICENSE +0 -0
  17. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/_native.pyi +0 -0
  18. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/config.py +0 -0
  19. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/console.py +0 -0
  20. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/constraints.py +0 -0
  21. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/decorators.py +0 -0
  22. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/errors.py +0 -0
  23. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/events.py +0 -0
  24. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/fields.py +0 -0
  25. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/joiner.py +0 -0
  26. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/manager.py +0 -0
  27. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/py.typed +0 -0
  28. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/score.py +0 -0
  29. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/solver.py +0 -0
  30. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/typing.py +0 -0
  31. {solverforge-0.6.4 → solverforge-0.6.6}/python/solverforge/ui.py +0 -0
  32. {solverforge-0.6.4 → solverforge-0.6.6}/rust-toolchain.toml +0 -0
  33. {solverforge-0.6.4 → solverforge-0.6.6}/src/bindings.rs +0 -0
  34. {solverforge-0.6.4 → solverforge-0.6.6}/src/callbacks/call.rs +0 -0
  35. {solverforge-0.6.4 → solverforge-0.6.6}/src/callbacks/mod.rs +0 -0
  36. {solverforge-0.6.4 → solverforge-0.6.6}/src/callbacks/registry.rs +0 -0
  37. {solverforge-0.6.4 → solverforge-0.6.6}/src/callbacks/threading.rs +0 -0
  38. {solverforge-0.6.4 → solverforge-0.6.6}/src/config.rs +0 -0
  39. {solverforge-0.6.4 → solverforge-0.6.6}/src/constraints/compiled.rs +0 -0
  40. {solverforge-0.6.4 → solverforge-0.6.6}/src/constraints/evaluate.rs +0 -0
  41. {solverforge-0.6.4 → solverforge-0.6.6}/src/constraints/incremental.rs +0 -0
  42. {solverforge-0.6.4 → solverforge-0.6.6}/src/constraints/list_precedence.rs +0 -0
  43. {solverforge-0.6.4 → solverforge-0.6.6}/src/constraints/matches.rs +0 -0
  44. {solverforge-0.6.4 → solverforge-0.6.6}/src/constraints/mod.rs +0 -0
  45. {solverforge-0.6.4 → solverforge-0.6.6}/src/constraints/state.rs +0 -0
  46. {solverforge-0.6.4 → solverforge-0.6.6}/src/constraints/stream_plan.rs +0 -0
  47. {solverforge-0.6.4 → solverforge-0.6.6}/src/descriptor/extractor.rs +0 -0
  48. {solverforge-0.6.4 → solverforge-0.6.6}/src/descriptor/mod.rs +0 -0
  49. {solverforge-0.6.4 → solverforge-0.6.6}/src/descriptor/variable.rs +0 -0
  50. {solverforge-0.6.4 → solverforge-0.6.6}/src/error.rs +0 -0
  51. {solverforge-0.6.4 → solverforge-0.6.6}/src/intern.rs +0 -0
  52. {solverforge-0.6.4 → solverforge-0.6.6}/src/lib.rs +0 -0
  53. {solverforge-0.6.4 → solverforge-0.6.6}/src/manager/candidate_trace.rs +0 -0
  54. {solverforge-0.6.4 → solverforge-0.6.6}/src/manager/events.rs +0 -0
  55. {solverforge-0.6.4 → solverforge-0.6.6}/src/manager/jobs.rs +0 -0
  56. {solverforge-0.6.4 → solverforge-0.6.6}/src/manager/mod.rs +0 -0
  57. {solverforge-0.6.4 → solverforge-0.6.6}/src/manager/provenance.rs +0 -0
  58. {solverforge-0.6.4 → solverforge-0.6.6}/src/proxy/entity.rs +0 -0
  59. {solverforge-0.6.4 → solverforge-0.6.6}/src/proxy/list.rs +0 -0
  60. {solverforge-0.6.4 → solverforge-0.6.6}/src/proxy/mod.rs +0 -0
  61. {solverforge-0.6.4 → solverforge-0.6.6}/src/proxy/solution.rs +0 -0
  62. {solverforge-0.6.4 → solverforge-0.6.6}/src/runtime/candidate_metric.rs +0 -0
  63. {solverforge-0.6.4 → solverforge-0.6.6}/src/runtime/dynamic_provider.rs +0 -0
  64. {solverforge-0.6.4 → solverforge-0.6.6}/src/runtime/list_slots.rs +0 -0
  65. {solverforge-0.6.4 → solverforge-0.6.6}/src/runtime/mod.rs +0 -0
  66. {solverforge-0.6.4 → solverforge-0.6.6}/src/schema/build.rs +0 -0
  67. {solverforge-0.6.4 → solverforge-0.6.6}/src/schema/compiled.rs +0 -0
  68. {solverforge-0.6.4 → solverforge-0.6.6}/src/schema/mod.rs +0 -0
  69. {solverforge-0.6.4 → solverforge-0.6.6}/src/schema/python.rs +0 -0
  70. {solverforge-0.6.4 → solverforge-0.6.6}/src/schema/runtime_plan.rs +0 -0
  71. {solverforge-0.6.4 → solverforge-0.6.6}/src/score.rs +0 -0
  72. {solverforge-0.6.4 → solverforge-0.6.6}/src/solver/api.rs +0 -0
  73. {solverforge-0.6.4 → solverforge-0.6.6}/src/solver/console.rs +0 -0
  74. {solverforge-0.6.4 → solverforge-0.6.6}/src/solver/mod.rs +0 -0
  75. {solverforge-0.6.4 → solverforge-0.6.6}/src/solver/progress.rs +0 -0
  76. {solverforge-0.6.4 → solverforge-0.6.6}/src/solver/result.rs +0 -0
  77. {solverforge-0.6.4 → solverforge-0.6.6}/src/solver/solvable.rs +0 -0
  78. {solverforge-0.6.4 → solverforge-0.6.6}/src/state/callback_view.rs +0 -0
  79. {solverforge-0.6.4 → solverforge-0.6.6}/src/state/clone.rs +0 -0
  80. {solverforge-0.6.4 → solverforge-0.6.6}/src/state/entity_table.rs +0 -0
  81. {solverforge-0.6.4 → solverforge-0.6.6}/src/state/mod.rs +0 -0
  82. {solverforge-0.6.4 → solverforge-0.6.6}/src/state/variables.rs +0 -0
  83. {solverforge-0.6.4 → solverforge-0.6.6}/src/ui.rs +0 -0
  84. {solverforge-0.6.4 → solverforge-0.6.6}/src/value.rs +0 -0
@@ -560,9 +560,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
560
560
 
561
561
  [[package]]
562
562
  name = "solverforge-bridge"
563
- version = "0.19.2"
563
+ version = "0.19.4"
564
564
  source = "registry+https://github.com/rust-lang/crates.io-index"
565
- checksum = "873efcf43f5def486ec5c17a6fa6907104a777252a793f907995acef694461c0"
565
+ checksum = "12530829741dfb79552c5cb4ae70ac621503b5e9438947cbf9b50257f712a997"
566
566
  dependencies = [
567
567
  "solverforge-config",
568
568
  "solverforge-core",
@@ -572,9 +572,9 @@ dependencies = [
572
572
 
573
573
  [[package]]
574
574
  name = "solverforge-config"
575
- version = "0.19.2"
575
+ version = "0.19.4"
576
576
  source = "registry+https://github.com/rust-lang/crates.io-index"
577
- checksum = "94ef71f259fdc7044df5ff33c02a67a7d78d484ce6dffa0b85c85a538566e3ba"
577
+ checksum = "5cf7440a6bbc97606dfae1be85e55de541d30cc9a25635ba58eb4e78395faf1c"
578
578
  dependencies = [
579
579
  "serde",
580
580
  "serde_yaml",
@@ -585,9 +585,9 @@ dependencies = [
585
585
 
586
586
  [[package]]
587
587
  name = "solverforge-console"
588
- version = "0.19.2"
588
+ version = "0.19.4"
589
589
  source = "registry+https://github.com/rust-lang/crates.io-index"
590
- checksum = "406ce34f0da19eb9a4b52de89023c7829654f4dbc59bce19825833e95c9ab4ee"
590
+ checksum = "ccca538afe6f31d83bb699e7d6ec7f679033b9713828b8139a897d81faf2ef9e"
591
591
  dependencies = [
592
592
  "num-format",
593
593
  "owo-colors",
@@ -597,18 +597,18 @@ dependencies = [
597
597
 
598
598
  [[package]]
599
599
  name = "solverforge-core"
600
- version = "0.19.2"
600
+ version = "0.19.4"
601
601
  source = "registry+https://github.com/rust-lang/crates.io-index"
602
- checksum = "054e5d842862f0d6b01985aa6b18b53bd949e9dbf0e9884f34e0538a9fa20b3c"
602
+ checksum = "79b244e1efabe25575798d8d53a1d4d43492167d5bc004bff9c7a9505f5aae1d"
603
603
  dependencies = [
604
604
  "thiserror",
605
605
  ]
606
606
 
607
607
  [[package]]
608
608
  name = "solverforge-scoring"
609
- version = "0.19.2"
609
+ version = "0.19.4"
610
610
  source = "registry+https://github.com/rust-lang/crates.io-index"
611
- checksum = "a31bb6c3c7defe9d16bfff25a35e79c98d2a1dd6081fdf8a5c81b0681cf0a251"
611
+ checksum = "4c097aee46dd2e6f09be86392e85fe28cb5eeb18c575f835e3c7ce768236ad79"
612
612
  dependencies = [
613
613
  "solverforge-core",
614
614
  "thiserror",
@@ -616,9 +616,9 @@ dependencies = [
616
616
 
617
617
  [[package]]
618
618
  name = "solverforge-solver"
619
- version = "0.19.2"
619
+ version = "0.19.4"
620
620
  source = "registry+https://github.com/rust-lang/crates.io-index"
621
- checksum = "3102fa53507af1c9acb41d3d0d4a52f5534fcb1b5d52c825355f2ab9120a9d35"
621
+ checksum = "7cb0d393e0c42c3e6e88c71e0a07a09b245225f20cfca05d4a5a01f1ddda2d8c"
622
622
  dependencies = [
623
623
  "rand",
624
624
  "rand_chacha",
@@ -644,7 +644,7 @@ dependencies = [
644
644
 
645
645
  [[package]]
646
646
  name = "solverforge_py"
647
- version = "0.6.4"
647
+ version = "0.6.6"
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.4"
3
+ version = "0.6.6"
4
4
  edition = "2021"
5
5
  license = "Apache-2.0"
6
6
  rust-version = "1.95"
@@ -26,7 +26,7 @@ name = "solverforge_py"
26
26
  crate-type = ["cdylib", "rlib"]
27
27
 
28
28
  [package.metadata.solverforge]
29
- version = "0.19.2"
29
+ version = "0.19.4"
30
30
 
31
31
  [dependencies]
32
32
  indexmap = "2.12.1"
@@ -42,12 +42,12 @@ toml = "0.9.8"
42
42
  tracing = "0.1.44"
43
43
  tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
44
44
 
45
- solverforge-config = { version = "=0.19.2" }
46
- solverforge-bridge = { version = "=0.19.2" }
47
- solverforge-console = { version = "=0.19.2", features = ["verbose-logging"] }
48
- solverforge-core = { version = "=0.19.2" }
49
- solverforge-scoring = { version = "=0.19.2" }
50
- solverforge-solver = { version = "=0.19.2" }
45
+ solverforge-config = { version = "=0.19.4" }
46
+ solverforge-bridge = { version = "=0.19.4" }
47
+ solverforge-console = { version = "=0.19.4", features = ["verbose-logging"] }
48
+ solverforge-core = { version = "=0.19.4" }
49
+ solverforge-scoring = { version = "=0.19.4" }
50
+ solverforge-solver = { version = "=0.19.4" }
51
51
  solverforge-ui = { version = "=0.7.0", default-features = false }
52
52
 
53
53
  [dev-dependencies]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solverforge
3
- Version: 0.6.4
3
+ Version: 0.6.6
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Science/Research
@@ -39,23 +39,25 @@ The native extension owns the working solution state in Rust so SolverForge can
39
39
  clone, mutate, and snapshot solutions safely. Python callbacks are the single
40
40
  constraint authoring surface.
41
41
 
42
- The package targets CPython 3.14 and Rust 1.95.0. This checkout targets the
43
- SolverForge Rust crates `0.19.2` and pins `solverforge-ui` to `0.7.0` through
44
- exact crates.io versions in `Cargo.toml` and registry checksums in `Cargo.lock`.
45
- There is no local path or Git dependency override. The `solverforge`
46
- `0.6.x` line is the current dynamic binding architecture and intentionally
47
- supersedes the older incompatible `0.2.x` and `0.3.0` artifacts in the same PyPI
48
- namespace. Those older artifacts exposed `SolverFactory`, `PlanningVariable`,
49
- Java service requirements, and other APIs that are not part of this package.
42
+ The package targets CPython 3.14 and Rust 1.95.0. This release-preparation
43
+ checkout pins the six published SolverForge Rust dependencies to `0.19.4` and
44
+ `solverforge-ui` to `0.7.0` through exact crates.io requirements in
45
+ `Cargo.toml` and registry checksums in `Cargo.lock`. There is no local path or
46
+ Git dependency override. The `solverforge` `0.6.x` line is the current dynamic
47
+ binding architecture and intentionally supersedes the older incompatible
48
+ `0.2.x` and `0.3.0` artifacts in the same PyPI namespace. Those older artifacts
49
+ exposed `SolverFactory`, `PlanningVariable`, Java service requirements, and
50
+ other APIs that are not part of this package.
50
51
 
51
52
  ## Installation
52
53
 
53
- The package version prepared by this checkout is `solverforge` `0.6.4`, targeting
54
- the published and locked SolverForge Rust `0.19.2` registry dependency base.
55
- Install the matching package after its Python release with:
54
+ The package version prepared by this checkout is `solverforge` `0.6.6`. Its
55
+ release-quality build requires the published and registry-locked SolverForge
56
+ Rust `0.19.4` dependency base. Install the matching package after its Python
57
+ release with:
56
58
 
57
59
  ```sh
58
- python3.14 -m pip install "solverforge==0.6.4"
60
+ python3.14 -m pip install "solverforge==0.6.6"
59
61
  ```
60
62
 
61
63
  The installable wheel contains the core `solverforge` package, native extension,
@@ -74,13 +76,13 @@ make hospital-run
74
76
  make deliveries-run PORT=7861
75
77
  ```
76
78
 
77
- After publication, the same source-checkout examples can be run against the
78
- installed package without `PYTHONPATH` or an editable checkout:
79
+ The same source-checkout examples can be run against the published package
80
+ without `PYTHONPATH` or an editable checkout:
79
81
 
80
82
  ```sh
81
83
  python3.14 -m venv .venv-examples
82
84
  . .venv-examples/bin/activate
83
- python -m pip install "solverforge[examples]==0.6.4"
85
+ python -m pip install "solverforge[examples]==0.6.6"
84
86
  python examples/nqueens.py
85
87
  python -m examples.solverforge_hospital
86
88
  python -m examples.solverforge_deliveries
@@ -240,8 +242,9 @@ Run `make help` for focused targets such as `make test-hospital`,
240
242
  either a Python callback or a row field name. Dual raw-schema sources are
241
243
  rejected at compilation, while immutable row metadata can be supplied without
242
244
  per-query Python callbacks. Provider-backed value ranges are imported once per
243
- variable and shared across rows in Rust-owned state; row candidate callbacks
244
- remain row-specific.
245
+ variable and shared across rows in Rust-owned state. Row candidate callbacks
246
+ remain row-specific and define move legality throughout construction and local
247
+ search, including assignment-group swaps and rematches.
245
248
  - `scalar_assignment_group(...)` declares assignment-aware scalar groups for
246
249
  grouped scalar local search and assignment-group construction. Group metadata
247
250
  covers required entities, capacity keys, assignment rules, ordering callbacks,
@@ -252,9 +255,12 @@ Run `make help` for focused targets such as `make test-hospital`,
252
255
  metadata, `required_entity_field`, `capacity_key_field`,
253
256
  `position_key_field`, and `sequence_key_field` are explicit alternatives to
254
257
  their callbacks: required is a row bool, position and sequence are row
255
- integers, and capacity is a row list indexed by candidate value. A field
256
- alternative is mutually exclusive with its callback and avoids
257
- per-candidate Python transitions without caching or changing callback behavior.
258
+ integers, and capacity is a row list indexed by candidate value.
259
+ `same_value_conflict_field` is the native alternative for a static
260
+ `assignment_rule`: each row lists adjacent-sequence entity indexes that
261
+ cannot hold the same assigned value, and the group still requires sequence
262
+ metadata. Field alternatives avoid per-candidate Python transitions without
263
+ caching or changing callback behavior.
258
264
  The compiled schema retains one immutable runtime plan (schema, descriptor,
259
265
  and model), reused by direct solves, manager jobs, snapshots, and resumes;
260
266
  instance rows, callback views, seeds, and moves remain per solve.
@@ -8,23 +8,25 @@ The native extension owns the working solution state in Rust so SolverForge can
8
8
  clone, mutate, and snapshot solutions safely. Python callbacks are the single
9
9
  constraint authoring surface.
10
10
 
11
- The package targets CPython 3.14 and Rust 1.95.0. This checkout targets the
12
- SolverForge Rust crates `0.19.2` and pins `solverforge-ui` to `0.7.0` through
13
- exact crates.io versions in `Cargo.toml` and registry checksums in `Cargo.lock`.
14
- There is no local path or Git dependency override. The `solverforge`
15
- `0.6.x` line is the current dynamic binding architecture and intentionally
16
- supersedes the older incompatible `0.2.x` and `0.3.0` artifacts in the same PyPI
17
- namespace. Those older artifacts exposed `SolverFactory`, `PlanningVariable`,
18
- Java service requirements, and other APIs that are not part of this package.
11
+ The package targets CPython 3.14 and Rust 1.95.0. This release-preparation
12
+ checkout pins the six published SolverForge Rust dependencies to `0.19.4` and
13
+ `solverforge-ui` to `0.7.0` through exact crates.io requirements in
14
+ `Cargo.toml` and registry checksums in `Cargo.lock`. There is no local path or
15
+ Git dependency override. The `solverforge` `0.6.x` line is the current dynamic
16
+ binding architecture and intentionally supersedes the older incompatible
17
+ `0.2.x` and `0.3.0` artifacts in the same PyPI namespace. Those older artifacts
18
+ exposed `SolverFactory`, `PlanningVariable`, Java service requirements, and
19
+ other APIs that are not part of this package.
19
20
 
20
21
  ## Installation
21
22
 
22
- The package version prepared by this checkout is `solverforge` `0.6.4`, targeting
23
- the published and locked SolverForge Rust `0.19.2` registry dependency base.
24
- Install the matching package after its Python release with:
23
+ The package version prepared by this checkout is `solverforge` `0.6.6`. Its
24
+ release-quality build requires the published and registry-locked SolverForge
25
+ Rust `0.19.4` dependency base. Install the matching package after its Python
26
+ release with:
25
27
 
26
28
  ```sh
27
- python3.14 -m pip install "solverforge==0.6.4"
29
+ python3.14 -m pip install "solverforge==0.6.6"
28
30
  ```
29
31
 
30
32
  The installable wheel contains the core `solverforge` package, native extension,
@@ -43,13 +45,13 @@ make hospital-run
43
45
  make deliveries-run PORT=7861
44
46
  ```
45
47
 
46
- After publication, the same source-checkout examples can be run against the
47
- installed package without `PYTHONPATH` or an editable checkout:
48
+ The same source-checkout examples can be run against the published package
49
+ without `PYTHONPATH` or an editable checkout:
48
50
 
49
51
  ```sh
50
52
  python3.14 -m venv .venv-examples
51
53
  . .venv-examples/bin/activate
52
- python -m pip install "solverforge[examples]==0.6.4"
54
+ python -m pip install "solverforge[examples]==0.6.6"
53
55
  python examples/nqueens.py
54
56
  python -m examples.solverforge_hospital
55
57
  python -m examples.solverforge_deliveries
@@ -209,8 +211,9 @@ Run `make help` for focused targets such as `make test-hospital`,
209
211
  either a Python callback or a row field name. Dual raw-schema sources are
210
212
  rejected at compilation, while immutable row metadata can be supplied without
211
213
  per-query Python callbacks. Provider-backed value ranges are imported once per
212
- variable and shared across rows in Rust-owned state; row candidate callbacks
213
- remain row-specific.
214
+ variable and shared across rows in Rust-owned state. Row candidate callbacks
215
+ remain row-specific and define move legality throughout construction and local
216
+ search, including assignment-group swaps and rematches.
214
217
  - `scalar_assignment_group(...)` declares assignment-aware scalar groups for
215
218
  grouped scalar local search and assignment-group construction. Group metadata
216
219
  covers required entities, capacity keys, assignment rules, ordering callbacks,
@@ -221,9 +224,12 @@ Run `make help` for focused targets such as `make test-hospital`,
221
224
  metadata, `required_entity_field`, `capacity_key_field`,
222
225
  `position_key_field`, and `sequence_key_field` are explicit alternatives to
223
226
  their callbacks: required is a row bool, position and sequence are row
224
- integers, and capacity is a row list indexed by candidate value. A field
225
- alternative is mutually exclusive with its callback and avoids
226
- per-candidate Python transitions without caching or changing callback behavior.
227
+ integers, and capacity is a row list indexed by candidate value.
228
+ `same_value_conflict_field` is the native alternative for a static
229
+ `assignment_rule`: each row lists adjacent-sequence entity indexes that
230
+ cannot hold the same assigned value, and the group still requires sequence
231
+ metadata. Field alternatives avoid per-candidate Python transitions without
232
+ caching or changing callback behavior.
227
233
  The compiled schema retains one immutable runtime plan (schema, descriptor,
228
234
  and model), reused by direct solves, manager jobs, snapshots, and resumes;
229
235
  instance rows, callback views, seeds, and moves remain per solve.
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "solverforge"
7
- version = "0.6.4"
7
+ version = "0.6.6"
8
8
  description = "Dynamic Python bindings for the SolverForge constraint solver."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -40,7 +40,7 @@ from .manager import JobHandle, SolverManager
40
40
  from .score import HardMediumSoftScore, HardSoftDecimalScore, HardSoftScore, SoftScore
41
41
  from .solver import Solver
42
42
 
43
- __version__ = "0.6.4"
43
+ __version__ = "0.6.6"
44
44
 
45
45
  __all__ = [
46
46
  "CallbackError",
@@ -51,6 +51,7 @@ class ScalarAssignmentGroup:
51
51
  capacity_key: Callable[..., object] | None = None
52
52
  capacity_key_field: str | None = None
53
53
  assignment_rule: Callable[..., object] | None = None
54
+ same_value_conflict_field: str | None = None
54
55
  position_key: Callable[..., object] | None = None
55
56
  position_key_field: str | None = None
56
57
  sequence_key: Callable[..., object] | None = None
@@ -95,6 +96,20 @@ class ScalarAssignmentGroup:
95
96
  raise TypeError(
96
97
  f"{field_name} must be a non-empty string when provided"
97
98
  )
99
+ if (
100
+ self.assignment_rule is not None
101
+ and self.same_value_conflict_field is not None
102
+ ):
103
+ raise TypeError(
104
+ "assignment_rule and same_value_conflict_field cannot both be configured"
105
+ )
106
+ if self.same_value_conflict_field is not None and (
107
+ not isinstance(self.same_value_conflict_field, str)
108
+ or not self.same_value_conflict_field
109
+ ):
110
+ raise TypeError(
111
+ "same_value_conflict_field must be a non-empty string when provided"
112
+ )
98
113
 
99
114
  def to_native(self) -> dict[str, Any]:
100
115
  return {
@@ -107,6 +122,7 @@ class ScalarAssignmentGroup:
107
122
  "capacity_key": self.capacity_key,
108
123
  "capacity_key_field": self.capacity_key_field,
109
124
  "assignment_rule": self.assignment_rule,
125
+ "same_value_conflict_field": self.same_value_conflict_field,
110
126
  "position_key": self.position_key,
111
127
  "position_key_field": self.position_key_field,
112
128
  "sequence_key": self.sequence_key,
@@ -128,6 +144,7 @@ def scalar_assignment_group(
128
144
  capacity_key: Callable[..., object] | None = None,
129
145
  capacity_key_field: str | None = None,
130
146
  assignment_rule: Callable[..., object] | None = None,
147
+ same_value_conflict_field: str | None = None,
131
148
  position_key: Callable[..., object] | None = None,
132
149
  position_key_field: str | None = None,
133
150
  sequence_key: Callable[..., object] | None = None,
@@ -145,6 +162,9 @@ def scalar_assignment_group(
145
162
  callback transitions while preserving the declared metadata values. The declaration compiles
146
163
  into one immutable native runtime plan rather than a Python-side assignment search path. Its
147
164
  target variable is exclusively searched through the resulting grouped selector.
165
+ ``same_value_conflict_field`` names a row list of adjacent-sequence entity indices that may
166
+ not share the same assigned value, providing a native conflict-graph alternative to
167
+ ``assignment_rule``.
148
168
  """
149
169
 
150
170
  return ScalarAssignmentGroup(
@@ -156,6 +176,7 @@ def scalar_assignment_group(
156
176
  capacity_key=capacity_key,
157
177
  capacity_key_field=capacity_key_field,
158
178
  assignment_rule=assignment_rule,
179
+ same_value_conflict_field=same_value_conflict_field,
159
180
  position_key=position_key,
160
181
  position_key_field=position_key_field,
161
182
  sequence_key=sequence_key,
@@ -222,6 +222,16 @@ def _scalar_groups(callbacks: object) -> list[dict[str, object]]:
222
222
  "assignment-rule groups need sequence metadata"
223
223
  )
224
224
  raise ModelValidationError(msg)
225
+ if (
226
+ item.same_value_conflict_field is not None
227
+ and item.sequence_key is None
228
+ and item.sequence_key_field is None
229
+ ):
230
+ msg = (
231
+ f"{item!r} declares a same_value_conflict_field but no sequence_key; "
232
+ "same-value conflict groups need sequence metadata"
233
+ )
234
+ raise ModelValidationError(msg)
225
235
  if item.name in names:
226
236
  msg = f"scalar group name `{item.name}` is declared more than once"
227
237
  raise ModelValidationError(msg)
@@ -100,6 +100,18 @@ fn build_assignment_group(
100
100
  group.name
101
101
  ));
102
102
  }
103
+ if group.assignment_rule.is_some() && group.same_value_conflict_field.is_some() {
104
+ return Err(format!(
105
+ "assignment scalar group `{}` cannot declare both assignment_rule and same_value_conflict_field",
106
+ group.name
107
+ ));
108
+ }
109
+ if group.same_value_conflict_field.is_some() && group.sequence_key.is_none() {
110
+ return Err(format!(
111
+ "assignment scalar group `{}` declares same_value_conflict_field but no sequence_key",
112
+ group.name
113
+ ));
114
+ }
103
115
 
104
116
  let slot = DynamicScalarVariableSlot::new(
105
117
  EntityClassId(entity_index),
@@ -368,6 +380,14 @@ impl PyDynamicAssignmentMetadata {
368
380
  right_entity: usize,
369
381
  right_value: usize,
370
382
  ) -> bool {
383
+ if let Some(field_name) = self.group().same_value_conflict_field.as_deref() {
384
+ if left_value != right_value {
385
+ return true;
386
+ }
387
+ return !self
388
+ .same_value_conflicts(solution, field_name, left_entity, right_entity)
389
+ .unwrap_or_else(panic_with_py_err);
390
+ }
371
391
  self.callback_value(
372
392
  solution,
373
393
  "assignment_rule",
@@ -377,6 +397,63 @@ impl PyDynamicAssignmentMetadata {
377
397
  .unwrap_or_else(panic_with_py_err)
378
398
  .unwrap_or(true)
379
399
  }
400
+
401
+ fn same_value_conflicts(
402
+ &self,
403
+ solution: &PyDynamicSolution,
404
+ field_name: &str,
405
+ left_entity: usize,
406
+ right_entity: usize,
407
+ ) -> PyResult<bool> {
408
+ Ok(
409
+ self.row_conflicts_with(solution, field_name, left_entity, right_entity)?
410
+ || self.row_conflicts_with(solution, field_name, right_entity, left_entity)?,
411
+ )
412
+ }
413
+
414
+ fn row_conflicts_with(
415
+ &self,
416
+ solution: &PyDynamicSolution,
417
+ field_name: &str,
418
+ entity_index: usize,
419
+ other_entity_index: usize,
420
+ ) -> PyResult<bool> {
421
+ let value = solution
422
+ .state
423
+ .entities
424
+ .get(self.entity_index)
425
+ .and_then(|rows| rows.get(entity_index))
426
+ .and_then(|row| row.fields.get(field_name))
427
+ .ok_or_else(|| {
428
+ py_err(format!(
429
+ "assignment scalar group `{}` field `{field_name}` is missing on row {entity_index}",
430
+ self.group().name
431
+ ))
432
+ })?;
433
+ let DynamicValue::List(conflicts) = value else {
434
+ return Err(py_err(format!(
435
+ "assignment scalar group `{}` same_value_conflict_field `{field_name}` must be a list of entity indices",
436
+ self.group().name
437
+ )));
438
+ };
439
+ let other_entity_index = i64::try_from(other_entity_index).map_err(|_| {
440
+ py_err(format!(
441
+ "assignment entity index `{other_entity_index}` does not fit in a field integer"
442
+ ))
443
+ })?;
444
+ for conflict in conflicts {
445
+ let DynamicValue::Int(conflict) = conflict else {
446
+ return Err(py_err(format!(
447
+ "assignment scalar group `{}` same_value_conflict_field `{field_name}` must contain only entity indices",
448
+ self.group().name
449
+ )));
450
+ };
451
+ if *conflict == other_entity_index {
452
+ return Ok(true);
453
+ }
454
+ }
455
+ Ok(false)
456
+ }
380
457
  }
381
458
 
382
459
  impl DynamicScalarAssignmentMetadata<PyDynamicSolution> for PyDynamicAssignmentMetadata {
@@ -389,7 +466,8 @@ impl DynamicScalarAssignmentMetadata<PyDynamicSolution> for PyDynamicAssignmentM
389
466
  sequence_key: group.sequence_key.is_some(),
390
467
  entity_order: group.entity_order.is_some(),
391
468
  value_order: group.value_order.is_some(),
392
- assignment_rule: group.assignment_rule.is_some(),
469
+ assignment_rule: group.assignment_rule.is_some()
470
+ || group.same_value_conflict_field.is_some(),
393
471
  }
394
472
  }
395
473
 
@@ -216,7 +216,13 @@ impl DynamicScalarAccess<PyDynamicSolution> for PyDynamicScalarAccess {
216
216
  .unwrap_or(&[])
217
217
  }
218
218
 
219
- fn value_is_legal(&self, solution: &PyDynamicSolution, _row: usize, value: usize) -> bool {
219
+ fn value_is_legal(&self, solution: &PyDynamicSolution, row: usize, value: usize) -> bool {
220
+ if let Some(candidates) = self
221
+ .row(solution, row)
222
+ .and_then(|row| row.candidates_at(self.variable.0))
223
+ {
224
+ return candidates.contains(&value);
225
+ }
220
226
  solution
221
227
  .state
222
228
  .scalar_value_range_at(self.entity.0, self.variable.0)
@@ -159,6 +159,7 @@ fn parse_assignment_scalar_groups(
159
159
  )?,
160
160
  capacity_key: optional_metadata_source(group, "capacity_key", "capacity_key_field")?,
161
161
  assignment_rule: optional_callable(group, "assignment_rule")?,
162
+ same_value_conflict_field: optional_str(group, "same_value_conflict_field")?,
162
163
  position_key: optional_metadata_source(group, "position_key", "position_key_field")?,
163
164
  sequence_key: optional_metadata_source(group, "sequence_key", "sequence_key_field")?,
164
165
  entity_order: optional_callable(group, "entity_order")?,
@@ -200,6 +200,7 @@ pub struct AssignmentScalarGroupSchema {
200
200
  pub required_entity: Option<MetadataSourceSchema>,
201
201
  pub capacity_key: Option<MetadataSourceSchema>,
202
202
  pub assignment_rule: Option<Py<PyAny>>,
203
+ pub same_value_conflict_field: Option<String>,
203
204
  pub position_key: Option<MetadataSourceSchema>,
204
205
  pub sequence_key: Option<MetadataSourceSchema>,
205
206
  pub entity_order: Option<Py<PyAny>>,
@@ -58,6 +58,28 @@ pub fn validate_dynamic_schema(schema: &DynamicSchema) -> Result<(), pyo3::PyErr
58
58
  group.name
59
59
  )));
60
60
  }
61
+ if group.assignment_rule.is_some() && group.same_value_conflict_field.is_some() {
62
+ return Err(py_err(format!(
63
+ "assignment scalar group `{}` cannot declare both assignment_rule and same_value_conflict_field",
64
+ group.name
65
+ )));
66
+ }
67
+ if group
68
+ .same_value_conflict_field
69
+ .as_ref()
70
+ .is_some_and(String::is_empty)
71
+ {
72
+ return Err(py_err(format!(
73
+ "assignment scalar group `{}` has an empty same_value_conflict_field",
74
+ group.name
75
+ )));
76
+ }
77
+ if group.same_value_conflict_field.is_some() && group.sequence_key.is_none() {
78
+ return Err(py_err(format!(
79
+ "assignment scalar group `{}` declares same_value_conflict_field but no sequence_key",
80
+ group.name
81
+ )));
82
+ }
61
83
  }
62
84
  Ok(())
63
85
  }
@@ -436,6 +436,9 @@ fn row_metadata_fields(schema: &DynamicSchema) -> BTreeSet<String> {
436
436
  field_names.insert(field.clone());
437
437
  }
438
438
  }
439
+ if let Some(field) = group.same_value_conflict_field.as_ref() {
440
+ field_names.insert(field.clone());
441
+ }
439
442
  }
440
443
  field_names
441
444
  }
@@ -499,10 +502,12 @@ fn validate_assignment_group_field_metadata(
499
502
  .sequence_key
500
503
  .as_ref()
501
504
  .and_then(MetadataSourceSchema::row);
505
+ let same_value_conflict_field = group.same_value_conflict_field.as_deref();
502
506
  if required_field.is_none()
503
507
  && capacity_field.is_none()
504
508
  && position_field.is_none()
505
509
  && sequence_field.is_none()
510
+ && same_value_conflict_field.is_none()
506
511
  {
507
512
  continue;
508
513
  }
@@ -560,6 +565,35 @@ fn validate_assignment_group_field_metadata(
560
565
  )));
561
566
  }
562
567
  }
568
+ if let Some(field) = same_value_conflict_field {
569
+ let value = assignment_metadata_field(row, &group.name, row_index, field)?;
570
+ let DynamicValue::List(conflicts) = value else {
571
+ return Err(crate::error::py_err(format!(
572
+ "assignment scalar group `{}` field `{field}` on row {row_index} must be a list of entity indices",
573
+ group.name
574
+ )));
575
+ };
576
+ for conflict in conflicts {
577
+ let DynamicValue::Int(conflict) = conflict else {
578
+ return Err(crate::error::py_err(format!(
579
+ "assignment scalar group `{}` field `{field}` on row {row_index} must contain only entity indices",
580
+ group.name
581
+ )));
582
+ };
583
+ let Ok(conflict) = usize::try_from(*conflict) else {
584
+ return Err(crate::error::py_err(format!(
585
+ "assignment scalar group `{}` field `{field}` on row {row_index} contains invalid entity index {conflict}",
586
+ group.name
587
+ )));
588
+ };
589
+ if conflict >= rows.len() {
590
+ return Err(crate::error::py_err(format!(
591
+ "assignment scalar group `{}` field `{field}` on row {row_index} contains out-of-range entity index {conflict}",
592
+ group.name
593
+ )));
594
+ }
595
+ }
596
+ }
563
597
  if let Some(field) = capacity_field {
564
598
  let value = assignment_metadata_field(row, &group.name, row_index, field)?;
565
599
  let DynamicValue::List(values) = value else {
@@ -671,10 +671,19 @@ impl DynamicModelBackend for PyDynamicSolution {
671
671
  fn scalar_value_is_legal(
672
672
  &self,
673
673
  entity: EntityClassId,
674
- _row: usize,
674
+ row: usize,
675
675
  variable: VariableId,
676
676
  value: usize,
677
677
  ) -> bool {
678
+ if let Some(candidates) = self
679
+ .state
680
+ .entities
681
+ .get(entity.0)
682
+ .and_then(|rows| rows.get(row))
683
+ .and_then(|row| row.candidates_at(variable.0))
684
+ {
685
+ return candidates.contains(&value);
686
+ }
678
687
  self.state
679
688
  .scalar_value_range_at(entity.0, variable.0)
680
689
  .is_some_and(|values| values.contains(&value))
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