From 7434bc4403a893cc416e29163b28b3145d92e4dd Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 14:28:29 +0900 Subject: [PATCH 01/11] docs(metadata-storage-v3): update proposal to match landed Rust API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect the narrow per-collection metadata accessors added on Instance / ParametricInstance during PR #843 review (`*_metadata()` / `*_metadata_mut()`), the explicit policy of not exposing `*_collection_mut()`, and the actual `insert_with(id, c, metadata) -> ()` shape on `ConstraintCollection`. Drop the stale "Parse helpers exposed" claim — those `*_to_v1` helpers are crate-internal and the element-level `from_bytes`/`to_bytes` rationale was removed in #845. Add a Follow-ups section listing the still-`pub` `active_mut`/`removed_mut`/`insert_with`, the deferred `NamedFunction` SoA migration, and the Python Series / `include=` / sidecar wave 2 work. Co-Authored-By: Claude Opus 4.7 (1M context) --- METADATA_STORAGE_V3.md | 108 ++++++++++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 17 deletions(-) diff --git a/METADATA_STORAGE_V3.md b/METADATA_STORAGE_V3.md index f89ae8033..2fb464f7d 100644 --- a/METADATA_STORAGE_V3.md +++ b/METADATA_STORAGE_V3.md @@ -297,18 +297,25 @@ pub struct Constraint = Created> { Standalone constraints (`Constraint::equal_to_zero(f)`, `OneHotConstraint::new(...)`, etc.) carry no metadata at the Rust -level. Insertion drains a staging bag (Python wrappers) or accepts an -explicit metadata argument: +level. Insertion picks an unused id and writes element + metadata +through `insert_with`: ```rust -let id = collection.insert(Constraint::equal_to_zero(f)); +let id = collection.unused_id(); +collection.insert_with( + id, + Constraint::equal_to_zero(f), + ConstraintMetadata::default(), +); collection.metadata_mut().set_name(id, "demand_balance"); collection.metadata_mut().push_subscripts(id, [i, j]); -// or atomically with metadata via insert_with, taking the existing -// owned ConstraintMetadata struct directly (the SoA store and the -// owned struct are mutually convertible): +// or atomically — the SoA store and the owned ConstraintMetadata +// struct are mutually convertible, so the metadata can be supplied +// up-front: +let id = collection.unused_id(); collection.insert_with( + id, Constraint::equal_to_zero(f), ConstraintMetadata { name: Some("demand_balance".into()), @@ -359,6 +366,42 @@ impl ConstraintCollection { } ``` +`Instance` and `ParametricInstance` expose narrow per-collection +accessors so callers don't have to go through +`constraint_collection_mut()` (which would hand out raw `&mut` on +the active / removed maps and break the collection's invariants): + +```rust +impl Instance { + // immutable: full collection (active / removed / metadata) is fine. + pub fn constraint_collection(&self) -> &ConstraintCollection; + pub fn indicator_constraint_collection(&self) -> &ConstraintCollection; + pub fn one_hot_constraint_collection(&self) -> &ConstraintCollection; + pub fn sos1_constraint_collection(&self) -> &ConstraintCollection; + + // metadata-only mut: invariant-safe, since metadata is keyed by id + // independent of active / removed membership. + pub fn variable_metadata(&self) -> &VariableMetadataStore; + pub fn variable_metadata_mut(&mut self) -> &mut VariableMetadataStore; + pub fn constraint_metadata(&self) -> &ConstraintMetadataStore; + pub fn constraint_metadata_mut(&mut self) -> &mut ConstraintMetadataStore; + pub fn indicator_constraint_metadata(&self) -> &ConstraintMetadataStore; + pub fn indicator_constraint_metadata_mut(&mut self) -> &mut ConstraintMetadataStore; + pub fn one_hot_constraint_metadata(&self) -> &ConstraintMetadataStore; + pub fn one_hot_constraint_metadata_mut(&mut self) -> &mut ConstraintMetadataStore; + pub fn sos1_constraint_metadata(&self) -> &ConstraintMetadataStore; + pub fn sos1_constraint_metadata_mut(&mut self) -> &mut ConstraintMetadataStore; +} +// `ParametricInstance` mirrors the same surface. +``` + +Active / removed transitions go through dedicated invariant-safe +operations on the collection (`relax(id, reason)` / `restore(id)`). +There is intentionally no `constraint_collection_mut()`. Callers that +need to add a constraint use the existing `Instance::add_*` family, +which routes through `ConstraintCollection::insert_with` while +keeping the variable-id registration check. + The internal call sites that used to read `c.metadata.*` directly (e.g. `rust/ommx/src/sample_set/extract.rs`'s `metadata.name`, `metadata.subscripts`, `metadata.parameters` filters) switch to @@ -453,12 +496,15 @@ pub struct DecisionVariable( `ParametricInstance` / `Solution` / `SampleSet` pre-snapshot the SoA store and zip the metadata in alongside each item before handing the iterator to `entries_to_dataframe`. -- **Parse helpers exposed.** `decision_variable_to_v1`, +- **Parse helpers stay `pub(crate)`.** `decision_variable_to_v1`, `evaluated_decision_variable_to_v1`, `sampled_decision_variable_to_v1`, `constraint_to_v1`, `removed_constraint_to_v1`, - `evaluated_constraint_to_v1`, `sampled_constraint_to_v1` are now `pub` - in the Rust crate so the wrappers' `from_bytes` / `to_bytes` cycles - preserve metadata across serialization. + `evaluated_constraint_to_v1`, `sampled_constraint_to_v1` reconstruct + the v1 wire shape from a per-element value plus its metadata, but + they are crate-internal — element-level `from_bytes` / `to_bytes` + on the Python wrappers were removed in PR #845, so the only callers + are the `Instance` / `ParametricInstance` / `Solution` / `SampleSet` + serialize paths inside this crate. The semantic consequence is that mutations on a snapshot wrapper do not propagate back to the originating instance: `c = instance.constraints[5]; @@ -835,10 +881,19 @@ shape is: - The Rust `metadata` field on `DecisionVariable`, `Constraint`, `IndicatorConstraint`, `OneHotConstraint`, `Sos1Constraint`, and the `Evaluated*` / `Sampled*` siblings is **removed**. Downstream - Rust crates that touched `c.metadata.*` directly switch to the - collection-level accessors (`instance.constraint_collection().metadata()`, - `instance.variable_metadata()`, …) or the per-element-with-metadata - helpers (`store.collect_for(id) -> ConstraintMetadata`). + Rust crates that touched `c.metadata.*` directly switch to the narrow + per-collection accessors on `Instance` / + `ParametricInstance` — `constraint_metadata()` / `_mut()`, + `indicator_constraint_metadata()` / `_mut()`, + `one_hot_constraint_metadata()` / `_mut()`, + `sos1_constraint_metadata()` / `_mut()`, + `variable_metadata()` / `_mut()` — or the per-element-with-metadata + helper `store.collect_for(id) -> ConstraintMetadata`. There is no + `constraint_collection_mut()`: handing out raw `&mut` on the active / + removed maps would let callers break the collection's invariants + (variable-id registration, active/removed disjointness), so mutation + goes through the dedicated `Instance::add_*` / `relax` / `restore` + operations and the metadata-only `_mut` accessors above. - Python wrapper-object metadata getters (`.name`, `.subscripts`, `.parameters`, `.description`) are **preserved**; the user-visible surface is unchanged. Internally each wrapper now carries an owned @@ -926,15 +981,18 @@ traceability with earlier review comments. ```rust impl ConstraintCollection { - pub fn insert(&mut self, c: T::Created) -> T::ID; + pub fn unused_id(&self) -> T::ID; pub fn insert_with( &mut self, + id: T::ID, c: T::Created, metadata: ConstraintMetadata, - ) -> T::ID; + ); } - let id = collection.insert_with( + let id = collection.unused_id(); + collection.insert_with( + id, c, ConstraintMetadata { name: Some("demand_balance".into()), @@ -1005,6 +1063,22 @@ traceability with earlier review comments. pattern needs revisiting (e.g. weak-handle variant of the wrapper) is a question to take up at implementation time, not before. +## Follow-ups (post-#843) + +- **Tighten `ConstraintCollection` mutation surface.** + `active_mut()` / `removed_mut()` / `insert_with()` are still `pub` + on `ConstraintCollection` itself, even though no public caller + outside the crate needs them — `Instance` now exposes only + invariant-safe operations (`add_*`, `relax`, `restore`, + `*_metadata_mut`). These three methods should be narrowed to + `pub(crate)` (or smaller) in a follow-up so the only way to break + the collection's invariants is from inside the crate. +- **`NamedFunction` SoA migration.** Track the + `NamedFunctionMetadataStore` work described under + "NamedFunction (deferred — separate PR)" above. +- **Python Series / `include=` / sidecar dfs.** Wave 2 of the Python + surface, blocked on the snapshot-vs-attached decision. + ## Open questions None remaining. All eight items are resolved above. From 6efcb39a49be4c6ffeb77fc13a17e708c0b80a6f Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 14:28:49 +0900 Subject: [PATCH 02/11] feat(pandas): add include= to wide *_df methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Sequence[str] include= parameter to the wide DataFrame methods on Instance / ParametricInstance / Solution / SampleSet. The default ("metadata", "parameters") preserves the v2-equivalent column shape; include=[] yields core columns only; include=["metadata"] / include=["parameters"] keep just the named family. Unknown values raise ValueError. Implementation: each per-row dict is post-filtered in entries_to_dataframe based on the resolved IncludeFlags. The ToPandasEntry impls are unchanged — they still emit every column unconditionally and the helper drops the gated ones before the DataFrame is built. Mechanical breaking change: every `*_df` accessor turns from #[getter] (property) into a regular method, so call sites must add parentheses. Existing tests + the one doctest in solution.rs are updated. removed_reasons_df / *_removed_reasons_df also lose their property syntax for API consistency, even though they don't take include= (no metadata/parameters columns to filter). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/api_reference.json | 1800 ++++++++++++----- .../tests/test_dataframe_include.py | 130 ++ .../tests/test_indicator_constraint.py | 2 +- .../tests/test_one_hot_sos1_constraints.py | 24 +- python/ommx/ommx/_ommx_rust/__init__.pyi | 557 ++--- python/ommx/src/instance.rs | 71 +- python/ommx/src/pandas.rs | 88 +- python/ommx/src/parametric_instance.rs | 47 +- python/ommx/src/sample_set.rs | 66 +- python/ommx/src/solution.rs | 76 +- 10 files changed, 2038 insertions(+), 823 deletions(-) create mode 100644 python/ommx-tests/tests/test_dataframe_include.py diff --git a/docs/api/api_reference.json b/docs/api/api_reference.json index be3107c34..63ba07b1a 100644 --- a/docs/api/api_reference.json +++ b/docs/api/api_reference.json @@ -7087,6 +7087,47 @@ "is_async": false, "deprecated": null }, + { + "name": "constraints_df", + "doc": "DataFrame of constraints", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "convert_all_indicators_to_constraints", "doc": "Convert every active indicator constraint to regular constraints using Big-M.\n\nSee {meth}`~ommx.v1.Instance.convert_indicator_to_constraint` for the\nconversion rule. Returns a dict mapping each original indicator ID to the\nlist of regular constraint IDs it produced.\n\nAtomic: every active indicator is validated up front, and only if every\none is convertible are the conversions applied. If any indicator fails\nvalidation (non-finite bound on a required side), no mutation happens and\nthe instance is left untouched.", @@ -7316,6 +7357,47 @@ "is_async": false, "deprecated": null }, + { + "name": "decision_variables_df", + "doc": "DataFrame of decision variables", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "empty", "doc": "Create trivial empty instance of minimization with zero objective, no constraints, and no decision variables.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance\n>>> instance = Instance.empty()\n>>> instance.sense == Instance.MINIMIZE\nTrue\n```", @@ -7842,6 +7924,47 @@ "is_async": false, "deprecated": null }, + { + "name": "indicator_constraints_df", + "doc": "DataFrame of indicator constraints", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "load_mps", "doc": "", @@ -7945,6 +8068,88 @@ "is_async": false, "deprecated": null }, + { + "name": "named_functions_df", + "doc": "DataFrame of named functions", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "one_hot_constraints_df", + "doc": "DataFrame of one-hot constraints", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "partial_evaluate", "doc": "Creates a new instance with specific decision variables fixed to given values.\n\nThis method substitutes the specified decision variables with their provided values,\ncreating a new problem instance where these variables are fixed. This is useful for\nscenarios such as:\n\n- Creating simplified sub-problems with some variables fixed\n- Incrementally solving a problem by fixing some variables and optimizing the rest\n- Testing specific configurations of a problem\n\n**Args:**\n- `state`: Maps decision variable IDs to their fixed values.\n Can be a {class}`~ommx.v1.State` object or a dictionary mapping variable IDs to values.\n- `atol`: Absolute tolerance for floating point comparisons. If None, uses the default tolerance.\n\n**Returns:**\nA new instance with the specified decision variables fixed to their given values.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = DecisionVariable.binary(1)\n>>> y = DecisionVariable.binary(2)\n>>> instance = Instance.from_components(\n... decision_variables=[x, y],\n... objective=x + y,\n... constraints=[x + y <= 1],\n... sense=Instance.MINIMIZE\n... )\n>>> new_instance = instance.partial_evaluate({1: 1})\n>>> new_instance.objective\nFunction(x2 + 1)\n```\n\nSubstituted value is stored in the decision variable:\n\n```python\n>>> x = new_instance.get_decision_variable_by_id(1)\n>>> x.substituted_value\n1.0\n```", @@ -8257,21 +8462,40 @@ "deprecated": null }, { - "name": "required_ids", - "doc": "Get the set of decision variable IDs used in the objective and remaining constraints.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.required_ids()\n{0, 1, 2}\n```", + "name": "removed_constraints_df", + "doc": "DataFrame of removed constraints", "signatures": [ { - "parameters": [], - "return_type": { - "display": "set[int]", - "link_target": null, - "children": [ - { - "display": "int", + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", "link_target": null, - "children": [] + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" } - ] + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] } } ], @@ -8279,23 +8503,38 @@ "deprecated": null }, { - "name": "restore_constraint", - "doc": "", + "name": "removed_indicator_constraints_df", + "doc": "DataFrame of removed indicator constraints", "signatures": [ { "parameters": [ { - "name": "constraint_id", + "name": "include", "type_": { - "display": "int", + "display": "Optional[Sequence[str]]", "link_target": null, - "children": [] + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] }, - "default": null + "default": { + "kind": "Simple", + "value": "None" + } } ], "return_type": { - "display": "None", + "display": "DataFrame", "link_target": null, "children": [] } @@ -8305,19 +8544,149 @@ "deprecated": null }, { - "name": "restore_indicator_constraint", - "doc": "Restore a removed indicator constraint back to active.", + "name": "removed_one_hot_constraints_df", + "doc": "DataFrame of removed one-hot constraints", "signatures": [ { "parameters": [ { - "name": "constraint_id", + "name": "include", "type_": { - "display": "int", + "display": "Optional[Sequence[str]]", "link_target": null, - "children": [] - }, - "default": null + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "removed_sos1_constraints_df", + "doc": "DataFrame of removed SOS1 constraints", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "required_ids", + "doc": "Get the set of decision variable IDs used in the objective and remaining constraints.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.required_ids()\n{0, 1, 2}\n```", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "set[int]", + "link_target": null, + "children": [ + { + "display": "int", + "link_target": null, + "children": [] + } + ] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "restore_constraint", + "doc": "", + "signatures": [ + { + "parameters": [ + { + "name": "constraint_id", + "type_": { + "display": "int", + "link_target": null, + "children": [] + }, + "default": null + } + ], + "return_type": { + "display": "None", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "restore_indicator_constraint", + "doc": "Restore a removed indicator constraint back to active.", + "signatures": [ + { + "parameters": [ + { + "name": "constraint_id", + "type_": { + "display": "int", + "link_target": null, + "children": [] + }, + "default": null } ], "return_type": { @@ -8368,6 +8737,47 @@ "is_async": false, "deprecated": null }, + { + "name": "sos1_constraints_df", + "doc": "DataFrame of SOS1 constraints", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "stats", "doc": "Get statistics about the instance.\n\nReturns a dictionary containing counts of decision variables and constraints\ncategorized by kind, usage, and status.\n\n**Returns:**\nA dictionary with the following structure:\n\n```text\n{\n \"decision_variables\": {\n \"total\": int,\n \"by_kind\": {\n \"binary\": int,\n \"integer\": int,\n \"continuous\": int,\n \"semi_integer\": int,\n \"semi_continuous\": int\n },\n \"by_usage\": {\n \"used_in_objective\": int,\n \"used_in_constraints\": int,\n \"used\": int,\n \"fixed\": int,\n \"dependent\": int,\n \"irrelevant\": int\n }\n },\n \"constraints\": {\n \"total\": int,\n \"active\": int,\n \"removed\": int\n }\n}\n```\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance\n>>> instance = Instance.empty()\n>>> stats = instance.stats()\n>>> stats[\"decision_variables\"][\"total\"]\n0\n>>> stats[\"constraints\"][\"total\"]\n0\n```", @@ -8684,17 +9094,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "constraints_df", - "doc": "DataFrame of constraints", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "created", "doc": "", @@ -8761,17 +9160,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "decision_variables_df", - "doc": "DataFrame of decision variables", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "description", "doc": "", @@ -8811,17 +9199,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "indicator_constraints_df", - "doc": "DataFrame of indicator constraints", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "license", "doc": "", @@ -8872,17 +9249,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "named_functions_df", - "doc": "DataFrame of named functions", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "num_constraints", "doc": "", @@ -8952,17 +9318,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "one_hot_constraints_df", - "doc": "DataFrame of one-hot constraints", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "removed_constraints", "doc": "Dict of all removed constraints in the instance keyed by their IDs.", @@ -8985,17 +9340,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "removed_constraints_df", - "doc": "DataFrame of removed constraints", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "removed_indicator_constraints", "doc": "Dict of all removed indicator constraints in the instance keyed by their IDs.", @@ -9018,17 +9362,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "removed_indicator_constraints_df", - "doc": "DataFrame of removed indicator constraints", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "removed_one_hot_constraints", "doc": "Dict of all removed one-hot constraints in the instance keyed by their IDs.", @@ -9051,17 +9384,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "removed_one_hot_constraints_df", - "doc": "DataFrame of removed one-hot constraints", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "removed_sos1_constraints", "doc": "Dict of all removed SOS1 constraints in the instance keyed by their IDs.", @@ -9085,19 +9407,8 @@ "is_readonly": true }, { - "name": "removed_sos1_constraints_df", - "doc": "DataFrame of removed SOS1 constraints", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, - { - "name": "required_capabilities", - "doc": "The non-standard constraint capabilities this instance currently uses.\n\nReturns the set of :class:`AdditionalCapability` values corresponding to\nthe active (non-removed) constraint collections the instance contains.\nAn empty set means the instance only uses regular constraints.\n\nCallers can diff this against an adapter's\n``ADDITIONAL_CAPABILITIES`` to see what would be converted, or use\n:meth:`reduce_capabilities` to perform the conversion.", + "name": "required_capabilities", + "doc": "The non-standard constraint capabilities this instance currently uses.\n\nReturns the set of :class:`AdditionalCapability` values corresponding to\nthe active (non-removed) constraint collections the instance contains.\nAn empty set means the instance only uses regular constraints.\n\nCallers can diff this against an adapter's\n``ADDITIONAL_CAPABILITIES`` to see what would be converted, or use\n:meth:`reduce_capabilities` to perform the conversion.", "type_": { "display": "set[AdditionalCapability]", "link_target": null, @@ -9145,17 +9456,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "sos1_constraints_df", - "doc": "DataFrame of SOS1 constraints", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "title", "doc": "", @@ -12312,6 +12612,88 @@ "is_async": false, "deprecated": null }, + { + "name": "constraints_df", + "doc": "DataFrame of constraints", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "decision_variables_df", + "doc": "DataFrame of decision variables", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "empty", "doc": "Create trivial empty instance of minimization with zero objective, no constraints, and no decision variables and parameters.", @@ -12694,6 +13076,129 @@ "is_async": false, "deprecated": null }, + { + "name": "named_functions_df", + "doc": "DataFrame of named functions", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "parameters_df", + "doc": "DataFrame of parameters", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "removed_constraints_df", + "doc": "DataFrame of removed constraints", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "to_bytes", "doc": "", @@ -12808,17 +13313,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "constraints_df", - "doc": "DataFrame of constraints", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "created", "doc": "", @@ -12885,17 +13379,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "decision_variables_df", - "doc": "DataFrame of decision variables", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "description", "doc": "", @@ -12946,17 +13429,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "named_functions_df", - "doc": "DataFrame of named functions", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "num_constraints", "doc": "", @@ -13039,17 +13511,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "parameters_df", - "doc": "DataFrame of parameters", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "removed_constraints", "doc": "", @@ -13073,19 +13534,8 @@ "is_readonly": true }, { - "name": "removed_constraints_df", - "doc": "DataFrame of removed constraints", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, - { - "name": "sense", - "doc": "", + "name": "sense", + "doc": "", "type_": { "display": "Sense", "link_target": null, @@ -16156,6 +16606,88 @@ "is_async": false, "deprecated": null }, + { + "name": "constraints_df", + "doc": "DataFrame of constraints with per-sample value and feasibility columns.\nStatic columns: id, equality, used_ids, name, subscripts, description.\nDynamic columns: value.{sample_id} and feasible.{sample_id} for each sample.", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "decision_variables_df", + "doc": "DataFrame of decision variables with per-sample value columns.\nStatic columns: id, kind, lower, upper, name, subscripts, description.\nDynamic columns: one per sample_id (int) with the variable's value.", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "extract_all_decision_variables", "doc": "Extract all decision variables grouped by name for a given sample ID.\n\nReturns a mapping from variable name to a mapping from subscripts to values.\nThis is useful for extracting all variables at once in a structured format.\nVariables without names are not included in the result.\n\nRaises ValueError if a decision variable with parameters is found, or if the same\nname and subscript combination is found multiple times, or if the sample ID is invalid.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> sample_set = instance.evaluate_samples({0: {i: 1 for i in range(5)}})\n>>> all_vars = sample_set.extract_all_decision_variables(0)\n>>> all_vars[\"x\"]\n{(0,): 1.0, (1,): 1.0, (2,): 1.0}\n>>> all_vars[\"y\"]\n{(0,): 1.0, (1,): 1.0}\n```", @@ -16614,13 +17146,38 @@ "deprecated": null }, { - "name": "num_samples", - "doc": "", + "name": "indicator_constraints_df", + "doc": "DataFrame of indicator constraints with per-sample value, feasibility, and indicator_active columns.\nStatic columns: id, indicator_variable_id, equality, used_ids, name, subscripts, description.\nDynamic columns: value.{sample_id}, feasible.{sample_id}, indicator_active.{sample_id} for each sample.", "signatures": [ { - "parameters": [], + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], "return_type": { - "display": "int", + "display": "DataFrame", "link_target": null, "children": [] } @@ -16630,21 +17187,56 @@ "deprecated": null }, { - "name": "sample_ids", - "doc": "", + "name": "indicator_removed_reasons_df", + "doc": "DataFrame of removed indicator constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`indicator_constraints_df` using the `id` index.", "signatures": [ { "parameters": [], "return_type": { - "display": "set[int]", + "display": "DataFrame", "link_target": null, - "children": [ - { - "display": "int", + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "named_functions_df", + "doc": "DataFrame of named functions with per-sample value columns.\nStatic columns: id, used_ids, name, subscripts, description, parameters.\nDynamic columns: one per sample_id (int) with the function's evaluated value.", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", "link_target": null, - "children": [] + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" } - ] + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] } } ], @@ -16652,13 +17244,13 @@ "deprecated": null }, { - "name": "to_bytes", + "name": "num_samples", "doc": "", "signatures": [ { "parameters": [], "return_type": { - "display": "bytes", + "display": "int", "link_target": null, "children": [] } @@ -16666,50 +17258,218 @@ ], "is_async": false, "deprecated": null - } - ], - "attributes": [ + }, { - "name": "annotations", - "doc": "Returns a **copy** of the annotations dictionary.\n\nMutating the returned dict will **not** update the object.\nUse {meth}`add_user_annotation` or assign to {attr}`annotations`\nto modify annotations.", - "type_": { - "display": "dict[str, str]", - "link_target": null, - "children": [ - { - "display": "str", + "name": "one_hot_constraints_df", + "doc": "DataFrame of one-hot constraints with per-sample feasibility and active_variable columns.\nStatic columns: id, used_ids, name, subscripts, description.\nDynamic columns: feasible.{sample_id}, active_variable.{sample_id} for each sample.", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", "link_target": null, "children": [] - }, - { - "display": "str", + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "one_hot_removed_reasons_df", + "doc": "DataFrame of removed one-hot constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`one_hot_constraints_df` using the `id` index.", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", "link_target": null, "children": [] } - ] - }, - "is_property": true + } + ], + "is_async": false, + "deprecated": null }, { - "name": "best_feasible", - "doc": "", - "type_": { - "display": "Solution", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true + "name": "removed_reasons_df", + "doc": "DataFrame of removed constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`constraints_df` using the `id` index.", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null }, { - "name": "best_feasible_id", + "name": "sample_ids", "doc": "", - "type_": { - "display": "int", - "link_target": null, - "children": [] - }, - "is_property": true, + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "set[int]", + "link_target": null, + "children": [ + { + "display": "int", + "link_target": null, + "children": [] + } + ] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "sos1_constraints_df", + "doc": "DataFrame of SOS1 constraints with per-sample feasibility and active_variable columns.\nStatic columns: id, used_ids, name, subscripts, description.\nDynamic columns: feasible.{sample_id}, active_variable.{sample_id} for each sample.", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "sos1_removed_reasons_df", + "doc": "DataFrame of removed SOS1 constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`sos1_constraints_df` using the `id` index.", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "to_bytes", + "doc": "", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "bytes", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + } + ], + "attributes": [ + { + "name": "annotations", + "doc": "Returns a **copy** of the annotations dictionary.\n\nMutating the returned dict will **not** update the object.\nUse {meth}`add_user_annotation` or assign to {attr}`annotations`\nto modify annotations.", + "type_": { + "display": "dict[str, str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + }, + { + "display": "str", + "link_target": null, + "children": [] + } + ] + }, + "is_property": true + }, + { + "name": "best_feasible", + "doc": "", + "type_": { + "display": "Solution", + "link_target": null, + "children": [] + }, + "is_property": true, + "is_readonly": true + }, + { + "name": "best_feasible_id", + "doc": "", + "type_": { + "display": "int", + "link_target": null, + "children": [] + }, + "is_property": true, "is_readonly": true }, { @@ -16762,17 +17522,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "constraints_df", - "doc": "DataFrame of constraints with per-sample value and feasibility columns.\nStatic columns: id, equality, used_ids, name, subscripts, description.\nDynamic columns: value.{sample_id} and feasible.{sample_id} for each sample.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "decision_variable_names", "doc": "Get all unique decision variable names in this sample set.\n\nReturns a set of all unique variable names. Variables without names are not included.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> sample_set = instance.evaluate_samples({0: {i: 1 for i in range(5)}})\n>>> sorted(sample_set.decision_variable_names)\n['x', 'y']\n```", @@ -16807,17 +17556,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "decision_variables_df", - "doc": "DataFrame of decision variables with per-sample value columns.\nStatic columns: id, kind, lower, upper, name, subscripts, description.\nDynamic columns: one per sample_id (int) with the variable's value.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "end", "doc": "", @@ -16900,28 +17638,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "indicator_constraints_df", - "doc": "DataFrame of indicator constraints with per-sample value, feasibility, and indicator_active columns.\nStatic columns: id, indicator_variable_id, equality, used_ids, name, subscripts, description.\nDynamic columns: value.{sample_id}, feasible.{sample_id}, indicator_active.{sample_id} for each sample.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, - { - "name": "indicator_removed_reasons_df", - "doc": "DataFrame of removed indicator constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`indicator_constraints_df` using the `id` index.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "instance", "doc": "", @@ -16988,17 +17704,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "named_functions_df", - "doc": "DataFrame of named functions with per-sample value columns.\nStatic columns: id, used_ids, name, subscripts, description, parameters.\nDynamic columns: one per sample_id (int) with the function's evaluated value.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "objectives", "doc": "Get objectives for all samples", @@ -17021,28 +17726,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "one_hot_constraints_df", - "doc": "DataFrame of one-hot constraints with per-sample feasibility and active_variable columns.\nStatic columns: id, used_ids, name, subscripts, description.\nDynamic columns: feasible.{sample_id}, active_variable.{sample_id} for each sample.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, - { - "name": "one_hot_removed_reasons_df", - "doc": "DataFrame of removed one-hot constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`one_hot_constraints_df` using the `id` index.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "parameters", "doc": "", @@ -17075,17 +17758,6 @@ }, "is_property": true }, - { - "name": "removed_reasons_df", - "doc": "DataFrame of removed constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`constraints_df` using the `id` index.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "sample_ids_list", "doc": "Get sample IDs as a list (property version)", @@ -17146,28 +17818,6 @@ }, "is_property": true }, - { - "name": "sos1_constraints_df", - "doc": "DataFrame of SOS1 constraints with per-sample feasibility and active_variable columns.\nStatic columns: id, used_ids, name, subscripts, description.\nDynamic columns: feasible.{sample_id}, active_variable.{sample_id} for each sample.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, - { - "name": "sos1_removed_reasons_df", - "doc": "DataFrame of removed SOS1 constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`sos1_constraints_df` using the `id` index.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "start", "doc": "", @@ -18062,22 +18712,104 @@ "deprecated": null }, { - "name": "extract_all_decision_variables", - "doc": "Extract all decision variables grouped by name.\n\nReturns a mapping from variable name to a mapping from subscripts to values.\nThis is useful for extracting all variables at once in a structured format.\nVariables without names are not included in the result.\n\nRaises ValueError if a decision variable with parameters is found, or if the same\nname and subscript combination is found multiple times.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> solution = instance.evaluate({i: 1 for i in range(5)})\n>>> all_vars = solution.extract_all_decision_variables()\n>>> all_vars[\"x\"]\n{(0,): 1.0, (1,): 1.0, (2,): 1.0}\n>>> all_vars[\"y\"]\n{(0,): 1.0, (1,): 1.0}\n```", + "name": "constraints_df", + "doc": "DataFrame of evaluated constraints\n\nColumns: id (index), equality, value, used_ids, name, subscripts, description, dual_variable", "signatures": [ { - "parameters": [], - "return_type": { - "display": "dict", - "link_target": null, - "children": [] - } - } - ], - "is_async": false, - "deprecated": null - }, - { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "decision_variables_df", + "doc": "DataFrame of evaluated decision variables\n\nColumns: id (index), kind, lower, upper, name, subscripts, description, substituted_value, value", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "extract_all_decision_variables", + "doc": "Extract all decision variables grouped by name.\n\nReturns a mapping from variable name to a mapping from subscripts to values.\nThis is useful for extracting all variables at once in a structured format.\nVariables without names are not included in the result.\n\nRaises ValueError if a decision variable with parameters is found, or if the same\nname and subscript combination is found multiple times.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i, name=\"x\", subscripts=[i]) for i in range(3)]\n>>> y = [DecisionVariable.binary(i+3, name=\"y\", subscripts=[i]) for i in range(2)]\n>>> instance = Instance.from_components(\n... decision_variables=x + y,\n... objective=sum(x) + sum(y),\n... constraints=[],\n... sense=Instance.MAXIMIZE,\n... )\n>>> solution = instance.evaluate({i: 1 for i in range(5)})\n>>> all_vars = solution.extract_all_decision_variables()\n>>> all_vars[\"x\"]\n{(0,): 1.0, (1,): 1.0, (2,): 1.0}\n>>> all_vars[\"y\"]\n{(0,): 1.0, (1,): 1.0}\n```", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "dict", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { "name": "extract_all_named_functions", "doc": "Extract all named functions grouped by name (returns a Python dict)", "signatures": [ @@ -18411,6 +19143,177 @@ "is_async": false, "deprecated": null }, + { + "name": "indicator_constraints_df", + "doc": "DataFrame of evaluated indicator constraints\n\nColumns: id (index), indicator_variable_id, equality, value, indicator_active, used_ids, name, subscripts, description", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "indicator_removed_reasons_df", + "doc": "DataFrame of removed indicator constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`indicator_constraints_df` using the `id` index.", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "named_functions_df", + "doc": "DataFrame of evaluated named functions\n\nColumns: id (index), value, used_ids, name, subscripts, description, parameters.{key}", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "one_hot_constraints_df", + "doc": "DataFrame of evaluated one-hot constraints\n\nColumns: id (index), feasible, active_variable, used_ids, name, subscripts, description", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "one_hot_removed_reasons_df", + "doc": "DataFrame of removed one-hot constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`one_hot_constraints_df` using the `id` index.", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "removed_reasons_df", + "doc": "DataFrame of removed constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`constraints_df` on the `id` index.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[\n... (x[0] + x[1] == 1).set_id(10),\n... (x[1] + x[2] == 1).set_id(20),\n... ],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.relax_constraint(10, \"test_reason\")\n>>> solution = instance.evaluate({0: 1, 1: 0, 2: 1})\n```\n\n`removed_reasons_df` contains only removed constraints:\n\n```python\n>>> solution.removed_reasons_df()\n removed_reason\nid\n10 test_reason\n```\n\nJoin with `constraints_df` to get full information:\n\n```python\n>>> df = solution.constraints_df().join(solution.removed_reasons_df())\n>>> df[[\"value\", \"removed_reason\"]]\n value removed_reason\nid\n10 0.0 test_reason\n20 0.0 NaN\n```", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "set_dual_variable", "doc": "Set the dual variable value for a specific constraint by ID", @@ -18452,6 +19355,63 @@ "is_async": false, "deprecated": null }, + { + "name": "sos1_constraints_df", + "doc": "DataFrame of evaluated SOS1 constraints\n\nColumns: id (index), feasible, active_variable, used_ids, name, subscripts, description", + "signatures": [ + { + "parameters": [ + { + "name": "include", + "type_": { + "display": "Optional[Sequence[str]]", + "link_target": null, + "children": [ + { + "display": "Sequence[str]", + "link_target": null, + "children": [ + { + "display": "str", + "link_target": null, + "children": [] + } + ] + } + ] + }, + "default": { + "kind": "Simple", + "value": "None" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "sos1_removed_reasons_df", + "doc": "DataFrame of removed SOS1 constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`sos1_constraints_df` using the `id` index.", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "to_bytes", "doc": "", @@ -18589,17 +19549,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "constraints_df", - "doc": "DataFrame of evaluated constraints\n\nColumns: id (index), equality, value, used_ids, name, subscripts, description, dual_variable", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "decision_variable_ids", "doc": "", @@ -18651,17 +19600,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "decision_variables_df", - "doc": "DataFrame of evaluated decision variables\n\nColumns: id (index), kind, lower, upper, name, subscripts, description, substituted_value, value", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "end", "doc": "", @@ -18711,28 +19649,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "indicator_constraints_df", - "doc": "DataFrame of evaluated indicator constraints\n\nColumns: id (index), indicator_variable_id, equality, value, indicator_active, used_ids, name, subscripts, description", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, - { - "name": "indicator_removed_reasons_df", - "doc": "DataFrame of removed indicator constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`indicator_constraints_df` using the `id` index.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "instance", "doc": "", @@ -18816,17 +19732,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "named_functions_df", - "doc": "DataFrame of evaluated named functions\n\nColumns: id (index), value, used_ids, name, subscripts, description, parameters.{key}", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "objective", "doc": "Get the objective function value", @@ -18838,28 +19743,6 @@ "is_property": true, "is_readonly": true }, - { - "name": "one_hot_constraints_df", - "doc": "DataFrame of evaluated one-hot constraints\n\nColumns: id (index), feasible, active_variable, used_ids, name, subscripts, description", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, - { - "name": "one_hot_removed_reasons_df", - "doc": "DataFrame of removed one-hot constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`one_hot_constraints_df` using the `id` index.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "optimality", "doc": "Get the optimality status", @@ -18912,17 +19795,6 @@ }, "is_property": true }, - { - "name": "removed_reasons_df", - "doc": "DataFrame of removed constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`constraints_df` on the `id` index.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[\n... (x[0] + x[1] == 1).set_id(10),\n... (x[1] + x[2] == 1).set_id(20),\n... ],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.relax_constraint(10, \"test_reason\")\n>>> solution = instance.evaluate({0: 1, 1: 0, 2: 1})\n```\n\n`removed_reasons_df` contains only removed constraints:\n\n```python\n>>> solution.removed_reasons_df\n removed_reason\nid\n10 test_reason\n```\n\nJoin with `constraints_df` to get full information:\n\n```python\n>>> df = solution.constraints_df.join(solution.removed_reasons_df)\n>>> df[[\"value\", \"removed_reason\"]]\n value removed_reason\nid\n10 0.0 test_reason\n20 0.0 NaN\n```", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "sense", "doc": "Get the optimization sense (minimize or maximize)", @@ -18966,28 +19838,6 @@ }, "is_property": true }, - { - "name": "sos1_constraints_df", - "doc": "DataFrame of evaluated SOS1 constraints\n\nColumns: id (index), feasible, active_variable, used_ids, name, subscripts, description", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, - { - "name": "sos1_removed_reasons_df", - "doc": "DataFrame of removed SOS1 constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`sos1_constraints_df` using the `id` index.", - "type_": { - "display": "DataFrame", - "link_target": null, - "children": [] - }, - "is_property": true, - "is_readonly": true - }, { "name": "start", "doc": "", diff --git a/python/ommx-tests/tests/test_dataframe_include.py b/python/ommx-tests/tests/test_dataframe_include.py new file mode 100644 index 000000000..5853dbfb7 --- /dev/null +++ b/python/ommx-tests/tests/test_dataframe_include.py @@ -0,0 +1,130 @@ +"""Tests for the `include=` parameter on wide `*_df` methods. + +Default `include` matches the v2-equivalent wide shape (`("metadata", +"parameters")`); `include=()` drops both metadata and parameter columns; +`include=("metadata",)` and `include=("parameters",)` keep only the named +family. +""" + +from __future__ import annotations + +import pytest +from ommx.v1 import ( + DecisionVariable, + Instance, + Constraint, +) + + +METADATA_COLS = {"name", "subscripts", "description"} + + +def _build_instance() -> Instance: + """Instance with metadata + parameters on decision variables and constraints.""" + x = [ + DecisionVariable.binary( + i, + name=f"x{i}", + subscripts=[i], + description=f"variable {i}", + parameters={"role": "primary", "shard": str(i)}, + ) + for i in range(3) + ] + c = (x[0] + x[1] + x[2] == 1).set_name("balance") + assert isinstance(c, Constraint) + return Instance.from_components( + decision_variables=x, + objective=sum(x), + constraints={10: c}, + sense=Instance.MAXIMIZE, + ) + + +# --------------------------------------------------------------------------- +# decision_variables_df — DV has both metadata and parameters columns +# --------------------------------------------------------------------------- + + +def test_decision_variables_df_default_includes_both(): + instance = _build_instance() + df = instance.decision_variables_df() + assert METADATA_COLS.issubset(df.columns) + assert "parameters.role" in df.columns + assert "parameters.shard" in df.columns + + +def test_decision_variables_df_include_empty_drops_both(): + instance = _build_instance() + df = instance.decision_variables_df(include=[]) + assert METADATA_COLS.isdisjoint(df.columns) + assert not any(c.startswith("parameters.") for c in df.columns) + + +def test_decision_variables_df_include_metadata_only(): + instance = _build_instance() + df = instance.decision_variables_df(include=["metadata"]) + assert METADATA_COLS.issubset(df.columns) + assert not any(c.startswith("parameters.") for c in df.columns) + + +def test_decision_variables_df_include_parameters_only(): + instance = _build_instance() + df = instance.decision_variables_df(include=["parameters"]) + assert METADATA_COLS.isdisjoint(df.columns) + assert "parameters.role" in df.columns + assert "parameters.shard" in df.columns + + +# --------------------------------------------------------------------------- +# constraints_df — Constraint has only metadata columns; parameters family +# is currently not emitted, so include=("parameters",) is a no-op. +# --------------------------------------------------------------------------- + + +def test_constraints_df_default_emits_metadata(): + instance = _build_instance() + df = instance.constraints_df() + assert METADATA_COLS.issubset(df.columns) + + +def test_constraints_df_include_empty_drops_metadata(): + instance = _build_instance() + df = instance.constraints_df(include=[]) + assert METADATA_COLS.isdisjoint(df.columns) + # core columns are still present + assert "equality" in df.columns + + +# --------------------------------------------------------------------------- +# Solution / SampleSet propagate the same shape +# --------------------------------------------------------------------------- + + +def test_solution_decision_variables_df_include_empty(): + instance = _build_instance() + sol = instance.evaluate({0: 1, 1: 0, 2: 0}) + df = sol.decision_variables_df(include=[]) + assert METADATA_COLS.isdisjoint(df.columns) + assert "value" in df.columns + + +def test_sample_set_decision_variables_df_include_empty(): + instance = _build_instance() + ss = instance.evaluate_samples({0: {0: 1, 1: 0, 2: 0}, 1: {0: 0, 1: 1, 2: 0}}) + df = ss.decision_variables_df(include=[]) + assert METADATA_COLS.isdisjoint(df.columns) + # per-sample value columns remain + assert 0 in df.columns + assert 1 in df.columns + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +def test_unknown_include_flag_raises_value_error(): + instance = _build_instance() + with pytest.raises(ValueError): + instance.decision_variables_df(include=["bogus"]) diff --git a/python/ommx-tests/tests/test_indicator_constraint.py b/python/ommx-tests/tests/test_indicator_constraint.py index 2878a091f..81c5c9a56 100644 --- a/python/ommx-tests/tests/test_indicator_constraint.py +++ b/python/ommx-tests/tests/test_indicator_constraint.py @@ -155,7 +155,7 @@ def test_removed_indicator_constraints_df_surfaces_reason_and_ids(): new_ids = instance.convert_indicator_to_constraint(7) - df = instance.removed_indicator_constraints_df + df = instance.removed_indicator_constraints_df() assert list(df.index) == [7] assert ( df.loc[7, "removed_reason"] == "ommx.Instance.convert_indicator_to_constraint" diff --git a/python/ommx-tests/tests/test_one_hot_sos1_constraints.py b/python/ommx-tests/tests/test_one_hot_sos1_constraints.py index 39ce3a66b..b3be1b911 100644 --- a/python/ommx-tests/tests/test_one_hot_sos1_constraints.py +++ b/python/ommx-tests/tests/test_one_hot_sos1_constraints.py @@ -273,10 +273,10 @@ def test_sos1_constraints_df_roundtrips_removed_metadata(): ) new_ids = instance.convert_sos1_to_constraints(7) - active_df = instance.sos1_constraints_df + active_df = instance.sos1_constraints_df() assert active_df.empty - removed_df = instance.removed_sos1_constraints_df + removed_df = instance.removed_sos1_constraints_df() assert list(removed_df.index) == [7] assert ( removed_df.loc[7, "removed_reason"] @@ -326,14 +326,14 @@ def test_solution_one_hot_constraints_df_surfaces_active_variable(): ) sol_feas = instance.evaluate({1: 0.0, 2: 1.0, 3: 0.0}) - df_feas = sol_feas.one_hot_constraints_df + df_feas = sol_feas.one_hot_constraints_df() assert list(df_feas.index) == [10] assert df_feas.loc[10, "feasible"] assert df_feas.loc[10, "active_variable"] == 2 assert df_feas.loc[10, "used_ids"] == {1, 2, 3} sol_infeas = instance.evaluate({1: 1.0, 2: 1.0, 3: 0.0}) - df_infeas = sol_infeas.one_hot_constraints_df + df_infeas = sol_infeas.one_hot_constraints_df() assert not df_infeas.loc[10, "feasible"] assert pd.isna(df_infeas.loc[10, "active_variable"]) @@ -353,13 +353,13 @@ def test_solution_sos1_constraints_df_surfaces_active_variable(): ) sol_one_active = instance.evaluate({1: 0.0, 2: 5.0, 3: 0.0}) - df = sol_one_active.sos1_constraints_df + df = sol_one_active.sos1_constraints_df() assert list(df.index) == [20] assert df.loc[20, "feasible"] assert df.loc[20, "active_variable"] == 2 sol_all_zero = instance.evaluate({1: 0.0, 2: 0.0, 3: 0.0}) - df_zero = sol_all_zero.sos1_constraints_df + df_zero = sol_all_zero.sos1_constraints_df() assert df_zero.loc[20, "feasible"] assert pd.isna(df_zero.loc[20, "active_variable"]) @@ -379,10 +379,10 @@ def test_solution_one_hot_removed_reasons_df_after_conversion(): sol = instance.evaluate({0: 1.0, 1: 0.0, 2: 0.0}) - active_df = sol.one_hot_constraints_df + active_df = sol.one_hot_constraints_df() assert list(active_df.index) == [7] - removed_df = sol.one_hot_removed_reasons_df + removed_df = sol.one_hot_removed_reasons_df() assert list(removed_df.index) == [7] assert ( removed_df.loc[7, "removed_reason"] @@ -405,7 +405,7 @@ def test_solution_sos1_removed_reasons_df_after_conversion(): sol = instance.evaluate({0: 1.0, 1: 0.0, 2: 0.0}) - removed_df = sol.sos1_removed_reasons_df + removed_df = sol.sos1_removed_reasons_df() assert list(removed_df.index) == [7] assert ( removed_df.loc[7, "removed_reason"] @@ -436,7 +436,7 @@ def test_sample_set_one_hot_constraints_df_dynamic_columns(): } ) - df = ss.one_hot_constraints_df + df = ss.one_hot_constraints_df() assert list(df.index) == [10] assert df.loc[10, "used_ids"] == {1, 2, 3} assert df.loc[10, "feasible.0"] @@ -466,7 +466,7 @@ def test_sample_set_sos1_constraints_df_dynamic_columns(): } ) - df = ss.sos1_constraints_df + df = ss.sos1_constraints_df() assert list(df.index) == [20] assert df.loc[20, "feasible.0"] assert df.loc[20, "active_variable.0"] == 2 @@ -490,7 +490,7 @@ def test_sample_set_one_hot_removed_reasons_df_after_conversion(): ss = instance.evaluate_samples({0: {0: 1.0, 1: 0.0, 2: 0.0}}) - removed_df = ss.one_hot_removed_reasons_df + removed_df = ss.one_hot_removed_reasons_df() assert list(removed_df.index) == [7] assert ( removed_df.loc[7, "removed_reason"] diff --git a/python/ommx/ommx/_ommx_rust/__init__.pyi b/python/ommx/ommx/_ommx_rust/__init__.pyi index 81f9cd2a0..d998b1724 100644 --- a/python/ommx/ommx/_ommx_rust/__init__.pyi +++ b/python/ommx/ommx/_ommx_rust/__init__.pyi @@ -1448,56 +1448,6 @@ class Instance: def description(self) -> typing.Optional[InstanceDescription]: ... @property def used_decision_variables(self) -> builtins.list[DecisionVariable]: ... - @property - def decision_variables_df(self) -> pandas.DataFrame: - r""" - DataFrame of decision variables - """ - @property - def constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of constraints - """ - @property - def indicator_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of indicator constraints - """ - @property - def removed_indicator_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed indicator constraints - """ - @property - def one_hot_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of one-hot constraints - """ - @property - def removed_one_hot_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed one-hot constraints - """ - @property - def sos1_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of SOS1 constraints - """ - @property - def removed_sos1_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed SOS1 constraints - """ - @property - def removed_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed constraints - """ - @property - def named_functions_df(self) -> pandas.DataFrame: - r""" - DataFrame of named functions - """ def add_user_annotation( self, key: builtins.str, @@ -2559,6 +2509,66 @@ class Instance: 0 ``` """ + def decision_variables_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of decision variables + """ + def constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of constraints + """ + def indicator_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of indicator constraints + """ + def removed_indicator_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of removed indicator constraints + """ + def one_hot_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of one-hot constraints + """ + def removed_one_hot_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of removed one-hot constraints + """ + def sos1_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of SOS1 constraints + """ + def removed_sos1_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of removed SOS1 constraints + """ + def removed_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of removed constraints + """ + def named_functions_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of named functions + """ def __copy__(self) -> Instance: ... def __deepcopy__(self, _memo: typing.Any) -> Instance: ... def as_minimization_problem(self) -> builtins.bool: @@ -3232,31 +3242,6 @@ class ParametricInstance: def decision_variable_ids(self) -> builtins.set[builtins.int]: ... @property def parameter_ids(self) -> builtins.set[builtins.int]: ... - @property - def decision_variables_df(self) -> pandas.DataFrame: - r""" - DataFrame of decision variables - """ - @property - def constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of constraints - """ - @property - def removed_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed constraints - """ - @property - def named_functions_df(self) -> pandas.DataFrame: - r""" - DataFrame of named functions - """ - @property - def parameters_df(self) -> pandas.DataFrame: - r""" - DataFrame of parameters - """ def add_user_annotation( self, key: builtins.str, @@ -3332,6 +3317,36 @@ class ParametricInstance: r""" Get a specific parameter by ID """ + def decision_variables_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of decision variables + """ + def constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of constraints + """ + def removed_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of removed constraints + """ + def named_functions_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of named functions + """ + def parameters_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of parameters + """ def __copy__(self) -> ParametricInstance: ... def __deepcopy__(self, _memo: typing.Any) -> ParametricInstance: ... @@ -3945,84 +3960,6 @@ class SampleSet: Summary DataFrame with per-constraint feasibility columns. Index is sample_id. """ - @property - def decision_variables_df(self) -> pandas.DataFrame: - r""" - DataFrame of decision variables with per-sample value columns. - Static columns: id, kind, lower, upper, name, subscripts, description. - Dynamic columns: one per sample_id (int) with the variable's value. - """ - @property - def constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of constraints with per-sample value and feasibility columns. - Static columns: id, equality, used_ids, name, subscripts, description. - Dynamic columns: value.{sample_id} and feasible.{sample_id} for each sample. - """ - @property - def removed_reasons_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed constraint reasons. - - Columns: id (index), removed_reason, removed_reason.{key} - - Can be joined with {attr}`constraints_df` using the `id` index. - """ - @property - def indicator_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of indicator constraints with per-sample value, feasibility, and indicator_active columns. - Static columns: id, indicator_variable_id, equality, used_ids, name, subscripts, description. - Dynamic columns: value.{sample_id}, feasible.{sample_id}, indicator_active.{sample_id} for each sample. - """ - @property - def indicator_removed_reasons_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed indicator constraint reasons. - - Columns: id (index), removed_reason, removed_reason.{key} - - Can be joined with {attr}`indicator_constraints_df` using the `id` index. - """ - @property - def one_hot_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of one-hot constraints with per-sample feasibility and active_variable columns. - Static columns: id, used_ids, name, subscripts, description. - Dynamic columns: feasible.{sample_id}, active_variable.{sample_id} for each sample. - """ - @property - def one_hot_removed_reasons_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed one-hot constraint reasons. - - Columns: id (index), removed_reason, removed_reason.{key} - - Can be joined with {attr}`one_hot_constraints_df` using the `id` index. - """ - @property - def sos1_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of SOS1 constraints with per-sample feasibility and active_variable columns. - Static columns: id, used_ids, name, subscripts, description. - Dynamic columns: feasible.{sample_id}, active_variable.{sample_id} for each sample. - """ - @property - def sos1_removed_reasons_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed SOS1 constraint reasons. - - Columns: id (index), removed_reason, removed_reason.{key} - - Can be joined with {attr}`sos1_constraints_df` using the `id` index. - """ - @property - def named_functions_df(self) -> pandas.DataFrame: - r""" - DataFrame of named functions with per-sample value columns. - Static columns: id, used_ids, name, subscripts, description, parameters. - Dynamic columns: one per sample_id (int) with the function's evaluated value. - """ def add_user_annotation( self, key: builtins.str, @@ -4127,6 +4064,86 @@ class SampleSet: """ def __copy__(self) -> SampleSet: ... def __deepcopy__(self, _memo: typing.Any) -> SampleSet: ... + def decision_variables_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of decision variables with per-sample value columns. + Static columns: id, kind, lower, upper, name, subscripts, description. + Dynamic columns: one per sample_id (int) with the variable's value. + """ + def constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of constraints with per-sample value and feasibility columns. + Static columns: id, equality, used_ids, name, subscripts, description. + Dynamic columns: value.{sample_id} and feasible.{sample_id} for each sample. + """ + def removed_reasons_df(self) -> pandas.DataFrame: + r""" + DataFrame of removed constraint reasons. + + Columns: id (index), removed_reason, removed_reason.{key} + + Can be joined with {attr}`constraints_df` using the `id` index. + """ + def indicator_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of indicator constraints with per-sample value, feasibility, and indicator_active columns. + Static columns: id, indicator_variable_id, equality, used_ids, name, subscripts, description. + Dynamic columns: value.{sample_id}, feasible.{sample_id}, indicator_active.{sample_id} for each sample. + """ + def indicator_removed_reasons_df(self) -> pandas.DataFrame: + r""" + DataFrame of removed indicator constraint reasons. + + Columns: id (index), removed_reason, removed_reason.{key} + + Can be joined with {attr}`indicator_constraints_df` using the `id` index. + """ + def one_hot_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of one-hot constraints with per-sample feasibility and active_variable columns. + Static columns: id, used_ids, name, subscripts, description. + Dynamic columns: feasible.{sample_id}, active_variable.{sample_id} for each sample. + """ + def one_hot_removed_reasons_df(self) -> pandas.DataFrame: + r""" + DataFrame of removed one-hot constraint reasons. + + Columns: id (index), removed_reason, removed_reason.{key} + + Can be joined with {attr}`one_hot_constraints_df` using the `id` index. + """ + def sos1_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of SOS1 constraints with per-sample feasibility and active_variable columns. + Static columns: id, used_ids, name, subscripts, description. + Dynamic columns: feasible.{sample_id}, active_variable.{sample_id} for each sample. + """ + def sos1_removed_reasons_df(self) -> pandas.DataFrame: + r""" + DataFrame of removed SOS1 constraint reasons. + + Columns: id (index), removed_reason, removed_reason.{key} + + Can be joined with {attr}`sos1_constraints_df` using the `id` index. + """ + def named_functions_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of named functions with per-sample value columns. + Static columns: id, used_ids, name, subscripts, description, parameters. + Dynamic columns: one per sample_id (int) with the function's evaluated value. + """ @typing.final class SampledConstraint: @@ -4453,122 +4470,6 @@ class Solution: r""" Get all unique named function names in this solution """ - @property - def decision_variables_df(self) -> pandas.DataFrame: - r""" - DataFrame of evaluated decision variables - - Columns: id (index), kind, lower, upper, name, subscripts, description, substituted_value, value - """ - @property - def constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of evaluated constraints - - Columns: id (index), equality, value, used_ids, name, subscripts, description, dual_variable - """ - @property - def removed_reasons_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed constraint reasons. - - Columns: id (index), removed_reason, removed_reason.{key} - - Can be joined with {attr}`constraints_df` on the `id` index. - - # Examples - - ```python - >>> from ommx.v1 import Instance, DecisionVariable - >>> x = [DecisionVariable.binary(i) for i in range(3)] - >>> instance = Instance.from_components( - ... decision_variables=x, - ... objective=sum(x), - ... constraints=[ - ... (x[0] + x[1] == 1).set_id(10), - ... (x[1] + x[2] == 1).set_id(20), - ... ], - ... sense=Instance.MAXIMIZE, - ... ) - >>> instance.relax_constraint(10, "test_reason") - >>> solution = instance.evaluate({0: 1, 1: 0, 2: 1}) - ``` - - `removed_reasons_df` contains only removed constraints: - - ```python - >>> solution.removed_reasons_df - removed_reason - id - 10 test_reason - ``` - - Join with `constraints_df` to get full information: - - ```python - >>> df = solution.constraints_df.join(solution.removed_reasons_df) - >>> df[["value", "removed_reason"]] - value removed_reason - id - 10 0.0 test_reason - 20 0.0 NaN - ``` - """ - @property - def indicator_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of evaluated indicator constraints - - Columns: id (index), indicator_variable_id, equality, value, indicator_active, used_ids, name, subscripts, description - """ - @property - def indicator_removed_reasons_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed indicator constraint reasons. - - Columns: id (index), removed_reason, removed_reason.{key} - - Can be joined with {attr}`indicator_constraints_df` using the `id` index. - """ - @property - def one_hot_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of evaluated one-hot constraints - - Columns: id (index), feasible, active_variable, used_ids, name, subscripts, description - """ - @property - def one_hot_removed_reasons_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed one-hot constraint reasons. - - Columns: id (index), removed_reason, removed_reason.{key} - - Can be joined with {attr}`one_hot_constraints_df` using the `id` index. - """ - @property - def sos1_constraints_df(self) -> pandas.DataFrame: - r""" - DataFrame of evaluated SOS1 constraints - - Columns: id (index), feasible, active_variable, used_ids, name, subscripts, description - """ - @property - def sos1_removed_reasons_df(self) -> pandas.DataFrame: - r""" - DataFrame of removed SOS1 constraint reasons. - - Columns: id (index), removed_reason, removed_reason.{key} - - Can be joined with {attr}`sos1_constraints_df` using the `id` index. - """ - @property - def named_functions_df(self) -> pandas.DataFrame: - r""" - DataFrame of evaluated named functions - - Columns: id (index), value, used_ids, name, subscripts, description, parameters.{key} - """ def add_user_annotation( self, key: builtins.str, @@ -4711,6 +4612,124 @@ class Solution: r""" Get a specific evaluated named function by ID """ + def decision_variables_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of evaluated decision variables + + Columns: id (index), kind, lower, upper, name, subscripts, description, substituted_value, value + """ + def constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of evaluated constraints + + Columns: id (index), equality, value, used_ids, name, subscripts, description, dual_variable + """ + def removed_reasons_df(self) -> pandas.DataFrame: + r""" + DataFrame of removed constraint reasons. + + Columns: id (index), removed_reason, removed_reason.{key} + + Can be joined with {attr}`constraints_df` on the `id` index. + + # Examples + + ```python + >>> from ommx.v1 import Instance, DecisionVariable + >>> x = [DecisionVariable.binary(i) for i in range(3)] + >>> instance = Instance.from_components( + ... decision_variables=x, + ... objective=sum(x), + ... constraints=[ + ... (x[0] + x[1] == 1).set_id(10), + ... (x[1] + x[2] == 1).set_id(20), + ... ], + ... sense=Instance.MAXIMIZE, + ... ) + >>> instance.relax_constraint(10, "test_reason") + >>> solution = instance.evaluate({0: 1, 1: 0, 2: 1}) + ``` + + `removed_reasons_df` contains only removed constraints: + + ```python + >>> solution.removed_reasons_df() + removed_reason + id + 10 test_reason + ``` + + Join with `constraints_df` to get full information: + + ```python + >>> df = solution.constraints_df().join(solution.removed_reasons_df()) + >>> df[["value", "removed_reason"]] + value removed_reason + id + 10 0.0 test_reason + 20 0.0 NaN + ``` + """ + def indicator_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of evaluated indicator constraints + + Columns: id (index), indicator_variable_id, equality, value, indicator_active, used_ids, name, subscripts, description + """ + def indicator_removed_reasons_df(self) -> pandas.DataFrame: + r""" + DataFrame of removed indicator constraint reasons. + + Columns: id (index), removed_reason, removed_reason.{key} + + Can be joined with {attr}`indicator_constraints_df` using the `id` index. + """ + def one_hot_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of evaluated one-hot constraints + + Columns: id (index), feasible, active_variable, used_ids, name, subscripts, description + """ + def one_hot_removed_reasons_df(self) -> pandas.DataFrame: + r""" + DataFrame of removed one-hot constraint reasons. + + Columns: id (index), removed_reason, removed_reason.{key} + + Can be joined with {attr}`one_hot_constraints_df` using the `id` index. + """ + def sos1_constraints_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of evaluated SOS1 constraints + + Columns: id (index), feasible, active_variable, used_ids, name, subscripts, description + """ + def sos1_removed_reasons_df(self) -> pandas.DataFrame: + r""" + DataFrame of removed SOS1 constraint reasons. + + Columns: id (index), removed_reason, removed_reason.{key} + + Can be joined with {attr}`sos1_constraints_df` using the `id` index. + """ + def named_functions_df( + self, include: typing.Optional[typing.Sequence[builtins.str]] = None + ) -> pandas.DataFrame: + r""" + DataFrame of evaluated named functions + + Columns: id (index), value, used_ids, name, subscripts, description, parameters.{key} + """ def __copy__(self) -> Solution: ... def __deepcopy__(self, _memo: typing.Any) -> Solution: ... def total_violation_l1(self) -> builtins.float: diff --git a/python/ommx/src/instance.rs b/python/ommx/src/instance.rs index be30f7ed5..9f03a70b4 100644 --- a/python/ommx/src/instance.rs +++ b/python/ommx/src/instance.rs @@ -1691,8 +1691,13 @@ impl Instance { } /// DataFrame of decision variables - #[getter] - pub fn decision_variables_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn decision_variables_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let var_meta_store = self.inner.variable_metadata().clone(); let var_meta_view: Vec<(ommx::DecisionVariableMetadata, &ommx::DecisionVariable)> = self .inner @@ -1706,12 +1711,18 @@ impl Instance { .iter() .map(|(m, dv)| crate::pandas::WithMetadata::new(*dv, m)), "id", + flags, ) } /// DataFrame of constraints - #[getter] - pub fn constraints_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn constraints_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self.inner.constraint_collection().metadata().clone(); let view: Vec<( ommx::ConstraintMetadata, @@ -1728,15 +1739,18 @@ impl Instance { view.iter() .map(|(m, id, c)| crate::pandas::WithMetadata::new((*id, *c), m)), "id", + flags, ) } /// DataFrame of indicator constraints - #[getter] + #[pyo3(signature = (include = None))] pub fn indicator_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self .inner .indicator_constraint_collection() @@ -1757,15 +1771,18 @@ impl Instance { view.iter() .map(|(m, id, c)| crate::pandas::WithMetadata::new((*id, *c), m)), "id", + flags, ) } /// DataFrame of removed indicator constraints - #[getter] + #[pyo3(signature = (include = None))] pub fn removed_indicator_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self .inner .indicator_constraint_collection() @@ -1786,15 +1803,18 @@ impl Instance { view.iter() .map(|(m, id, pair)| crate::pandas::WithMetadata::new((*id, *pair), m)), "id", + flags, ) } /// DataFrame of one-hot constraints - #[getter] + #[pyo3(signature = (include = None))] pub fn one_hot_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self .inner .one_hot_constraint_collection() @@ -1815,15 +1835,18 @@ impl Instance { view.iter() .map(|(m, id, c)| crate::pandas::WithMetadata::new((*id, *c), m)), "id", + flags, ) } /// DataFrame of removed one-hot constraints - #[getter] + #[pyo3(signature = (include = None))] pub fn removed_one_hot_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self .inner .one_hot_constraint_collection() @@ -1844,12 +1867,18 @@ impl Instance { view.iter() .map(|(m, id, pair)| crate::pandas::WithMetadata::new((*id, *pair), m)), "id", + flags, ) } /// DataFrame of SOS1 constraints - #[getter] - pub fn sos1_constraints_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn sos1_constraints_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self.inner.sos1_constraint_collection().metadata().clone(); let view: Vec<( ommx::ConstraintMetadata, @@ -1866,15 +1895,18 @@ impl Instance { view.iter() .map(|(m, id, c)| crate::pandas::WithMetadata::new((*id, *c), m)), "id", + flags, ) } /// DataFrame of removed SOS1 constraints - #[getter] + #[pyo3(signature = (include = None))] pub fn removed_sos1_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self.inner.sos1_constraint_collection().metadata().clone(); let view: Vec<( ommx::ConstraintMetadata, @@ -1891,15 +1923,18 @@ impl Instance { view.iter() .map(|(m, id, pair)| crate::pandas::WithMetadata::new((*id, *pair), m)), "id", + flags, ) } /// DataFrame of removed constraints - #[getter] + #[pyo3(signature = (include = None))] pub fn removed_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self.inner.constraint_collection().metadata().clone(); let view: Vec<( ommx::ConstraintMetadata, @@ -1916,13 +1951,19 @@ impl Instance { view.iter() .map(|(m, id, pair)| crate::pandas::WithMetadata::new((*id, *pair), m)), "id", + flags, ) } /// DataFrame of named functions - #[getter] - pub fn named_functions_df<'py>(&self, py: Python<'py>) -> PyResult> { - entries_to_dataframe(py, self.inner.named_functions().values(), "id") + #[pyo3(signature = (include = None))] + pub fn named_functions_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; + entries_to_dataframe(py, self.inner.named_functions().values(), "id", flags) } fn __copy__(&self) -> Self { diff --git a/python/ommx/src/pandas.rs b/python/ommx/src/pandas.rs index 42161698d..24a4d22be 100644 --- a/python/ommx/src/pandas.rs +++ b/python/ommx/src/pandas.rs @@ -4,6 +4,7 @@ use fnv::FnvHashMap; use ommx::{ConstraintMetadata, DecisionVariableMetadata, Evaluate, VariableIDSet}; use pyo3::{ + exceptions::PyValueError, prelude::*, sync::PyOnceLock, types::{PyAny, PyDict, PyList, PySet, PyType}, @@ -64,6 +65,82 @@ impl pyo3_stub_gen::PyStubType for PyDataFrame { } } +// --------------------------------------------------------------------------- +// IncludeFlags — gates optional column families on wide `*_df` methods +// --------------------------------------------------------------------------- + +/// Which optional column families to fold into a wide `*_df` DataFrame. +/// +/// `metadata` toggles the `name` / `subscripts` / `description` columns. +/// `parameters` toggles the `parameters.{key}` columns. The default +/// (`Self::default_wide()`) preserves the v2-equivalent wide shape. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] +pub struct IncludeFlags { + pub metadata: bool, + pub parameters: bool, +} + +impl IncludeFlags { + /// Default for wide `*_df` — both metadata and parameters columns on. + pub fn default_wide() -> Self { + Self { + metadata: true, + parameters: true, + } + } + + /// Parse `include=[...]` arg from Python. `None` returns the wide default. + pub fn from_optional(include: Option>) -> PyResult { + match include { + None => Ok(Self::default_wide()), + Some(values) => { + let mut flags = Self::default(); + for v in &values { + match v.as_str() { + "metadata" => flags.metadata = true, + "parameters" => flags.parameters = true, + other => { + return Err(PyValueError::new_err(format!( + "unknown include flag: {other:?} (expected one of \"metadata\", \"parameters\")" + ))); + } + } + } + Ok(flags) + } + } + } +} + +const METADATA_KEYS: &[&str] = &["name", "subscripts", "description"]; + +/// Drop columns from a per-row dict according to the include flags. +/// +/// `metadata` columns are dropped by name; `parameters.*` columns are +/// dropped by prefix. Missing keys are silently skipped (some impls don't +/// emit every key). +fn apply_include_filter(dict: &Bound, include: IncludeFlags) -> PyResult<()> { + if !include.metadata { + for key in METADATA_KEYS { + if dict.contains(key)? { + dict.del_item(key)?; + } + } + } + if !include.parameters { + let to_drop: Vec = dict + .keys() + .iter() + .filter_map(|k| k.extract::().ok()) + .filter(|k| k.starts_with("parameters.")) + .collect(); + for key in to_drop { + dict.del_item(key)?; + } + } + Ok(()) +} + // --------------------------------------------------------------------------- // pandas.NA cache // --------------------------------------------------------------------------- @@ -115,13 +192,22 @@ impl ToPandasEntry for &T { } /// Build a `pandas.DataFrame` from an iterator of domain objects, indexed by `index_col`. +/// +/// `include` selects which optional column families to keep on each row; +/// dropped columns never reach the constructed DataFrame. Pass +/// [`IncludeFlags::default_wide()`] to preserve the v2-equivalent shape. pub fn entries_to_dataframe<'py, T: ToPandasEntry>( py: Python<'py>, items: impl Iterator, index_col: &str, + include: IncludeFlags, ) -> PyResult> { let entries: Vec> = items - .map(|item| item.to_pandas_entry(py).map(|d| d.into_any())) + .map(|item| { + let dict = item.to_pandas_entry(py)?; + apply_include_filter(&dict, include)?; + Ok(dict.into_any()) + }) .collect::>()?; raw_entries_to_dataframe(py, entries, index_col) } diff --git a/python/ommx/src/parametric_instance.rs b/python/ommx/src/parametric_instance.rs index 79d0844c3..07a3f268d 100644 --- a/python/ommx/src/parametric_instance.rs +++ b/python/ommx/src/parametric_instance.rs @@ -307,8 +307,13 @@ impl ParametricInstance { } /// DataFrame of decision variables - #[getter] - pub fn decision_variables_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn decision_variables_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let var_meta_store = self.inner.variable_metadata().clone(); let view: Vec<(ommx::DecisionVariableMetadata, &ommx::DecisionVariable)> = self .inner @@ -321,12 +326,18 @@ impl ParametricInstance { view.iter() .map(|(m, dv)| crate::pandas::WithMetadata::new(*dv, m)), "id", + flags, ) } /// DataFrame of constraints - #[getter] - pub fn constraints_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn constraints_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self.inner.constraint_collection().metadata().clone(); let view: Vec<( ommx::ConstraintMetadata, @@ -343,15 +354,18 @@ impl ParametricInstance { view.iter() .map(|(m, id, c)| crate::pandas::WithMetadata::new((*id, *c), m)), "id", + flags, ) } /// DataFrame of removed constraints - #[getter] + #[pyo3(signature = (include = None))] pub fn removed_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self.inner.constraint_collection().metadata().clone(); let view: Vec<( ommx::ConstraintMetadata, @@ -368,19 +382,30 @@ impl ParametricInstance { view.iter() .map(|(m, id, pair)| crate::pandas::WithMetadata::new((*id, *pair), m)), "id", + flags, ) } /// DataFrame of named functions - #[getter] - pub fn named_functions_df<'py>(&self, py: Python<'py>) -> PyResult> { - entries_to_dataframe(py, self.inner.named_functions().values(), "id") + #[pyo3(signature = (include = None))] + pub fn named_functions_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; + entries_to_dataframe(py, self.inner.named_functions().values(), "id", flags) } /// DataFrame of parameters - #[getter] - pub fn parameters_df<'py>(&self, py: Python<'py>) -> PyResult> { - entries_to_dataframe(py, self.inner.parameters().values(), "id") + #[pyo3(signature = (include = None))] + pub fn parameters_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; + entries_to_dataframe(py, self.inner.parameters().values(), "id", flags) } fn __copy__(&self) -> Self { diff --git a/python/ommx/src/sample_set.rs b/python/ommx/src/sample_set.rs index 3f8101c3d..b7fa8d7eb 100644 --- a/python/ommx/src/sample_set.rs +++ b/python/ommx/src/sample_set.rs @@ -592,8 +592,13 @@ impl SampleSet { /// DataFrame of decision variables with per-sample value columns. /// Static columns: id, kind, lower, upper, name, subscripts, description. /// Dynamic columns: one per sample_id (int) with the variable's value. - #[getter] - pub fn decision_variables_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn decision_variables_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let sample_ids = sorted_sample_ids(&self.inner); let var_meta_store = self.inner.variable_metadata().clone(); let view: Vec<( @@ -617,14 +622,20 @@ impl SampleSet { ) }), "id", + flags, ) } /// DataFrame of constraints with per-sample value and feasibility columns. /// Static columns: id, equality, used_ids, name, subscripts, description. /// Dynamic columns: value.{sample_id} and feasible.{sample_id} for each sample. - #[getter] - pub fn constraints_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn constraints_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let sample_ids = sorted_sample_ids(&self.inner); let meta_store = self.inner.constraints().metadata().clone(); let view: Vec<( @@ -649,6 +660,7 @@ impl SampleSet { ) }), "id", + flags, ) } @@ -657,9 +669,8 @@ impl SampleSet { /// Columns: id (index), removed_reason, removed_reason.{key} /// /// Can be joined with {attr}`constraints_df` using the `id` index. - #[getter] pub fn removed_reasons_df<'py>(&self, py: Python<'py>) -> PyResult> { - use crate::pandas::RemovedReasonEntry; + use crate::pandas::{IncludeFlags, RemovedReasonEntry}; entries_to_dataframe( py, self.inner @@ -671,17 +682,20 @@ impl SampleSet { reason, }), "id", + IncludeFlags::default_wide(), ) } /// DataFrame of indicator constraints with per-sample value, feasibility, and indicator_active columns. /// Static columns: id, indicator_variable_id, equality, used_ids, name, subscripts, description. /// Dynamic columns: value.{sample_id}, feasible.{sample_id}, indicator_active.{sample_id} for each sample. - #[getter] + #[pyo3(signature = (include = None))] pub fn indicator_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let sample_ids = sorted_sample_ids(&self.inner); let meta_store = self.inner.indicator_constraints().metadata().clone(); let view: Vec<( @@ -706,6 +720,7 @@ impl SampleSet { ) }), "id", + flags, ) } @@ -714,12 +729,11 @@ impl SampleSet { /// Columns: id (index), removed_reason, removed_reason.{key} /// /// Can be joined with {attr}`indicator_constraints_df` using the `id` index. - #[getter] pub fn indicator_removed_reasons_df<'py>( &self, py: Python<'py>, ) -> PyResult> { - use crate::pandas::RemovedReasonEntry; + use crate::pandas::{IncludeFlags, RemovedReasonEntry}; entries_to_dataframe( py, self.inner @@ -731,17 +745,20 @@ impl SampleSet { reason, }), "id", + IncludeFlags::default_wide(), ) } /// DataFrame of one-hot constraints with per-sample feasibility and active_variable columns. /// Static columns: id, used_ids, name, subscripts, description. /// Dynamic columns: feasible.{sample_id}, active_variable.{sample_id} for each sample. - #[getter] + #[pyo3(signature = (include = None))] pub fn one_hot_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let sample_ids = sorted_sample_ids(&self.inner); let meta_store = self.inner.one_hot_constraints().metadata().clone(); let view: Vec<( @@ -766,6 +783,7 @@ impl SampleSet { ) }), "id", + flags, ) } @@ -774,12 +792,11 @@ impl SampleSet { /// Columns: id (index), removed_reason, removed_reason.{key} /// /// Can be joined with {attr}`one_hot_constraints_df` using the `id` index. - #[getter] pub fn one_hot_removed_reasons_df<'py>( &self, py: Python<'py>, ) -> PyResult> { - use crate::pandas::RemovedReasonEntry; + use crate::pandas::{IncludeFlags, RemovedReasonEntry}; entries_to_dataframe( py, self.inner @@ -791,14 +808,20 @@ impl SampleSet { reason, }), "id", + IncludeFlags::default_wide(), ) } /// DataFrame of SOS1 constraints with per-sample feasibility and active_variable columns. /// Static columns: id, used_ids, name, subscripts, description. /// Dynamic columns: feasible.{sample_id}, active_variable.{sample_id} for each sample. - #[getter] - pub fn sos1_constraints_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn sos1_constraints_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let sample_ids = sorted_sample_ids(&self.inner); let meta_store = self.inner.sos1_constraints().metadata().clone(); let view: Vec<( @@ -823,6 +846,7 @@ impl SampleSet { ) }), "id", + flags, ) } @@ -831,12 +855,11 @@ impl SampleSet { /// Columns: id (index), removed_reason, removed_reason.{key} /// /// Can be joined with {attr}`sos1_constraints_df` using the `id` index. - #[getter] pub fn sos1_removed_reasons_df<'py>( &self, py: Python<'py>, ) -> PyResult> { - use crate::pandas::RemovedReasonEntry; + use crate::pandas::{IncludeFlags, RemovedReasonEntry}; entries_to_dataframe( py, self.inner @@ -848,14 +871,20 @@ impl SampleSet { reason, }), "id", + IncludeFlags::default_wide(), ) } /// DataFrame of named functions with per-sample value columns. /// Static columns: id, used_ids, name, subscripts, description, parameters. /// Dynamic columns: one per sample_id (int) with the function's evaluated value. - #[getter] - pub fn named_functions_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn named_functions_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let sample_ids = sorted_sample_ids(&self.inner); entries_to_dataframe( py, @@ -867,6 +896,7 @@ impl SampleSet { sample_ids: &sample_ids, }), "id", + flags, ) } } diff --git a/python/ommx/src/solution.rs b/python/ommx/src/solution.rs index d6a4acb27..0dee1ee9f 100644 --- a/python/ommx/src/solution.rs +++ b/python/ommx/src/solution.rs @@ -471,8 +471,13 @@ impl Solution { /// DataFrame of evaluated decision variables /// /// Columns: id (index), kind, lower, upper, name, subscripts, description, substituted_value, value - #[getter] - pub fn decision_variables_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn decision_variables_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let var_meta_store = self.inner.variable_metadata().clone(); let view: Vec<( ommx::DecisionVariableMetadata, @@ -488,14 +493,20 @@ impl Solution { view.iter() .map(|(m, dv)| crate::pandas::WithMetadata::new(*dv, m)), "id", + flags, ) } /// DataFrame of evaluated constraints /// /// Columns: id (index), equality, value, used_ids, name, subscripts, description, dual_variable - #[getter] - pub fn constraints_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn constraints_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self.inner.evaluated_constraints().metadata().clone(); let view: Vec<( ommx::ConstraintMetadata, @@ -512,6 +523,7 @@ impl Solution { view.iter() .map(|(m, id, c)| crate::pandas::WithMetadata::new((*id, *c), m)), "id", + flags, ) } @@ -542,7 +554,7 @@ impl Solution { /// `removed_reasons_df` contains only removed constraints: /// /// ```python - /// >>> solution.removed_reasons_df + /// >>> solution.removed_reasons_df() /// removed_reason /// id /// 10 test_reason @@ -551,16 +563,15 @@ impl Solution { /// Join with `constraints_df` to get full information: /// /// ```python - /// >>> df = solution.constraints_df.join(solution.removed_reasons_df) + /// >>> df = solution.constraints_df().join(solution.removed_reasons_df()) /// >>> df[["value", "removed_reason"]] /// value removed_reason /// id /// 10 0.0 test_reason /// 20 0.0 NaN /// ``` - #[getter] pub fn removed_reasons_df<'py>(&self, py: Python<'py>) -> PyResult> { - use crate::pandas::RemovedReasonEntry; + use crate::pandas::{IncludeFlags, RemovedReasonEntry}; entries_to_dataframe( py, self.inner @@ -572,17 +583,20 @@ impl Solution { reason, }), "id", + IncludeFlags::default_wide(), ) } /// DataFrame of evaluated indicator constraints /// /// Columns: id (index), indicator_variable_id, equality, value, indicator_active, used_ids, name, subscripts, description - #[getter] + #[pyo3(signature = (include = None))] pub fn indicator_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self .inner .evaluated_indicator_constraints() @@ -603,6 +617,7 @@ impl Solution { view.iter() .map(|(m, id, c)| crate::pandas::WithMetadata::new((*id, *c), m)), "id", + flags, ) } @@ -611,12 +626,11 @@ impl Solution { /// Columns: id (index), removed_reason, removed_reason.{key} /// /// Can be joined with {attr}`indicator_constraints_df` using the `id` index. - #[getter] pub fn indicator_removed_reasons_df<'py>( &self, py: Python<'py>, ) -> PyResult> { - use crate::pandas::RemovedReasonEntry; + use crate::pandas::{IncludeFlags, RemovedReasonEntry}; entries_to_dataframe( py, self.inner @@ -628,17 +642,20 @@ impl Solution { reason, }), "id", + IncludeFlags::default_wide(), ) } /// DataFrame of evaluated one-hot constraints /// /// Columns: id (index), feasible, active_variable, used_ids, name, subscripts, description - #[getter] + #[pyo3(signature = (include = None))] pub fn one_hot_constraints_df<'py>( &self, py: Python<'py>, + include: Option>, ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self .inner .evaluated_one_hot_constraints() @@ -659,6 +676,7 @@ impl Solution { view.iter() .map(|(m, id, c)| crate::pandas::WithMetadata::new((*id, *c), m)), "id", + flags, ) } @@ -667,12 +685,11 @@ impl Solution { /// Columns: id (index), removed_reason, removed_reason.{key} /// /// Can be joined with {attr}`one_hot_constraints_df` using the `id` index. - #[getter] pub fn one_hot_removed_reasons_df<'py>( &self, py: Python<'py>, ) -> PyResult> { - use crate::pandas::RemovedReasonEntry; + use crate::pandas::{IncludeFlags, RemovedReasonEntry}; entries_to_dataframe( py, self.inner @@ -684,14 +701,20 @@ impl Solution { reason, }), "id", + IncludeFlags::default_wide(), ) } /// DataFrame of evaluated SOS1 constraints /// /// Columns: id (index), feasible, active_variable, used_ids, name, subscripts, description - #[getter] - pub fn sos1_constraints_df<'py>(&self, py: Python<'py>) -> PyResult> { + #[pyo3(signature = (include = None))] + pub fn sos1_constraints_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; let meta_store = self.inner.evaluated_sos1_constraints().metadata().clone(); let view: Vec<( ommx::ConstraintMetadata, @@ -708,6 +731,7 @@ impl Solution { view.iter() .map(|(m, id, c)| crate::pandas::WithMetadata::new((*id, *c), m)), "id", + flags, ) } @@ -716,12 +740,11 @@ impl Solution { /// Columns: id (index), removed_reason, removed_reason.{key} /// /// Can be joined with {attr}`sos1_constraints_df` using the `id` index. - #[getter] pub fn sos1_removed_reasons_df<'py>( &self, py: Python<'py>, ) -> PyResult> { - use crate::pandas::RemovedReasonEntry; + use crate::pandas::{IncludeFlags, RemovedReasonEntry}; entries_to_dataframe( py, self.inner @@ -733,15 +756,26 @@ impl Solution { reason, }), "id", + IncludeFlags::default_wide(), ) } /// DataFrame of evaluated named functions /// /// Columns: id (index), value, used_ids, name, subscripts, description, parameters.{key} - #[getter] - pub fn named_functions_df<'py>(&self, py: Python<'py>) -> PyResult> { - entries_to_dataframe(py, self.inner.evaluated_named_functions().values(), "id") + #[pyo3(signature = (include = None))] + pub fn named_functions_df<'py>( + &self, + py: Python<'py>, + include: Option>, + ) -> PyResult> { + let flags = crate::pandas::IncludeFlags::from_optional(include)?; + entries_to_dataframe( + py, + self.inner.evaluated_named_functions().values(), + "id", + flags, + ) } fn __copy__(&self) -> Self { From 750c5794d9a5af46515377bf9ed35c6df4d5ee38 Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 14:50:33 +0900 Subject: [PATCH 03/11] feat(pandas): add long-format sidecar dataframes for metadata stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six new accessors on Instance / ParametricInstance / Solution / SampleSet that read directly from the SoA metadata stores: - constraint_metadata_df(kind=...) — id-indexed wide; columns name / subscripts / description, index = `{kind}_constraint_id`. - constraint_parameters_df(kind=...) — long format `{kind}_constraint_id, key, value`. One row per (id, parameter_key) pair; ids without parameters contribute zero rows. - constraint_provenance_df(kind=...) — long format `{kind}_constraint_id, step, source_kind, source_id`. One row per (id, step) in the provenance chain. Empty for directly-authored constraints. - constraint_removed_reasons_df(kind=...) — long format `{kind}_constraint_id, reason, key, value`. One row per (id, parameter_key) pair, plus one row with key/value=NA for ids whose reason has no parameters. - variable_metadata_df() — id-indexed wide for decision variables; index = `variable_id`. - variable_parameters_df() — long format `variable_id, key, value`. `kind` accepts "regular" / "indicator" / "one_hot" / "sos1" (default "regular"); unknown values raise ValueError. Index column names are kind-qualified (`regular_constraint_id` ≠ `indicator_constraint_id`) so accidental cross-id-space joins are visible in df.head() etc. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/api_reference.json | 632 +++++++++++++++++- .../tests/test_dataframe_sidecars.py | 253 +++++++ python/ommx/ommx/_ommx_rust/__init__.pyi | 155 +++++ python/ommx/src/instance.rs | 244 ++++++- python/ommx/src/pandas.rs | 246 ++++++- python/ommx/src/parametric_instance.rs | 225 ++++++- python/ommx/src/sample_set.rs | 262 +++++++- python/ommx/src/solution.rs | 261 +++++++- 8 files changed, 2253 insertions(+), 25 deletions(-) create mode 100644 python/ommx-tests/tests/test_dataframe_sidecars.py diff --git a/docs/api/api_reference.json b/docs/api/api_reference.json index 63ba07b1a..48a72d8ae 100644 --- a/docs/api/api_reference.json +++ b/docs/api/api_reference.json @@ -7087,6 +7087,122 @@ "is_async": false, "deprecated": null }, + { + "name": "constraint_metadata_df", + "doc": "Constraint metadata DataFrame (id-indexed wide format).\n\nOne row per constraint id (active + removed) with columns\n`name`, `subscripts`, `description`. Index column is\n`{kind}_constraint_id`. `kind` selects which constraint family\nto read: `\"regular\"`, `\"indicator\"`, `\"one_hot\"`, or `\"sos1\"`.", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_parameters_df", + "doc": "Constraint parameters DataFrame (long format).\n\nOne row per (constraint_id, parameter_key) pair. Columns:\n`{kind}_constraint_id`, `key`, `value`. Default RangeIndex.", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_provenance_df", + "doc": "Constraint provenance DataFrame (long format).\n\nOne row per (constraint_id, step) pair. Columns:\n`{kind}_constraint_id`, `step`, `source_kind`, `source_id`.", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_removed_reasons_df", + "doc": "Removed-constraint reasons DataFrame (long format).\n\nOne row per (constraint_id, parameter_key) pair, plus one row with\n`key`/`value` set to NA when the reason has no parameters. Columns:\n`{kind}_constraint_id`, `reason`, `key`, `value`.", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "constraints_df", "doc": "DataFrame of constraints", @@ -8999,6 +9115,38 @@ ], "is_async": false, "deprecated": null + }, + { + "name": "variable_metadata_df", + "doc": "Decision-variable metadata DataFrame (id-indexed wide format).\n\nColumns: `name`, `subscripts`, `description`. Index column =\n`variable_id`.", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "variable_parameters_df", + "doc": "Decision-variable parameters DataFrame (long format).\n\nOne row per (variable_id, parameter_key) pair. Columns:\n`variable_id`, `key`, `value`.", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null } ], "attributes": [ @@ -12612,6 +12760,122 @@ "is_async": false, "deprecated": null }, + { + "name": "constraint_metadata_df", + "doc": "Constraint metadata DataFrame (id-indexed). See\n{meth}`ommx.v1.Instance.constraint_metadata_df` for column / `kind=`\nsemantics.", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_parameters_df", + "doc": "Constraint parameters DataFrame (long format).", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_provenance_df", + "doc": "Constraint provenance DataFrame (long format).", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_removed_reasons_df", + "doc": "Removed-constraint reasons DataFrame (long format).", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "constraints_df", "doc": "DataFrame of constraints", @@ -13216,28 +13480,60 @@ "deprecated": null }, { - "name": "with_parameters", - "doc": "Substitute parameters to yield an instance.\n\nParameters can be provided as a dict mapping parameter IDs to their values.", + "name": "variable_metadata_df", + "doc": "Decision-variable metadata DataFrame (id-indexed).", "signatures": [ { - "parameters": [ - { - "name": "parameters", - "type_": { - "display": "Mapping[int, float]", - "link_target": null, - "children": [ - { - "display": "int", - "link_target": null, - "children": [] - }, - { - "display": "float", - "link_target": null, - "children": [] - } - ] + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "variable_parameters_df", + "doc": "Decision-variable parameters DataFrame (long format).", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "with_parameters", + "doc": "Substitute parameters to yield an instance.\n\nParameters can be provided as a dict mapping parameter IDs to their values.", + "signatures": [ + { + "parameters": [ + { + "name": "parameters", + "type_": { + "display": "Mapping[int, float]", + "link_target": null, + "children": [ + { + "display": "int", + "link_target": null, + "children": [] + }, + { + "display": "float", + "link_target": null, + "children": [] + } + ] }, "default": null } @@ -16606,6 +16902,122 @@ "is_async": false, "deprecated": null }, + { + "name": "constraint_metadata_df", + "doc": "Constraint metadata DataFrame (id-indexed). See\n{meth}`ommx.v1.Instance.constraint_metadata_df` for column / `kind=`\nsemantics. Reads from the sampled collection's metadata store.", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_parameters_df", + "doc": "Constraint parameters DataFrame (long format).", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_provenance_df", + "doc": "Constraint provenance DataFrame (long format).", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_removed_reasons_df", + "doc": "Removed-constraint reasons DataFrame (long format).", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "constraints_df", "doc": "DataFrame of constraints with per-sample value and feasibility columns.\nStatic columns: id, equality, used_ids, name, subscripts, description.\nDynamic columns: value.{sample_id} and feasible.{sample_id} for each sample.", @@ -17426,6 +17838,38 @@ ], "is_async": false, "deprecated": null + }, + { + "name": "variable_metadata_df", + "doc": "Decision-variable metadata DataFrame (id-indexed).", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "variable_parameters_df", + "doc": "Decision-variable parameters DataFrame (long format).", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null } ], "attributes": [ @@ -18711,6 +19155,122 @@ "is_async": false, "deprecated": null }, + { + "name": "constraint_metadata_df", + "doc": "Constraint metadata DataFrame (id-indexed). See\n{meth}`ommx.v1.Instance.constraint_metadata_df` for column / `kind=`\nsemantics. Reads from the evaluated collection's metadata store.", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_parameters_df", + "doc": "Constraint parameters DataFrame (long format).", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_provenance_df", + "doc": "Constraint provenance DataFrame (long format).", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "constraint_removed_reasons_df", + "doc": "Removed-constraint reasons DataFrame (long format).", + "signatures": [ + { + "parameters": [ + { + "name": "kind", + "type_": { + "display": "str", + "link_target": null, + "children": [] + }, + "default": { + "kind": "Simple", + "value": "'regular'" + } + } + ], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, { "name": "constraints_df", "doc": "DataFrame of evaluated constraints\n\nColumns: id (index), equality, value, used_ids, name, subscripts, description, dual_variable", @@ -19459,6 +20019,38 @@ ], "is_async": false, "deprecated": null + }, + { + "name": "variable_metadata_df", + "doc": "Decision-variable metadata DataFrame (id-indexed).", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null + }, + { + "name": "variable_parameters_df", + "doc": "Decision-variable parameters DataFrame (long format).", + "signatures": [ + { + "parameters": [], + "return_type": { + "display": "DataFrame", + "link_target": null, + "children": [] + } + } + ], + "is_async": false, + "deprecated": null } ], "attributes": [ diff --git a/python/ommx-tests/tests/test_dataframe_sidecars.py b/python/ommx-tests/tests/test_dataframe_sidecars.py new file mode 100644 index 000000000..8b71c98f0 --- /dev/null +++ b/python/ommx-tests/tests/test_dataframe_sidecars.py @@ -0,0 +1,253 @@ +"""Tests for the long-format / id-indexed sidecar DataFrames. + +The 6 sidecar accessors on Instance / ParametricInstance / Solution / +SampleSet are derived views over the SoA metadata stores. They expose +metadata in shapes that the wide `*_df` cannot represent without +column-space explosion (provenance chains, per-id parameter maps with +arbitrary keys). +""" + +from __future__ import annotations + +import math +import pytest +import pandas as pd +from ommx.v1 import ( + Constraint, + DecisionVariable, + Equality, + IndicatorConstraint, + Instance, + OneHotConstraint, + Sos1Constraint, +) + + +def _instance_with_metadata() -> Instance: + """Instance carrying metadata + parameters on regular constraints + variables. + + Variable 0 has 2 parameters; variables 1, 2 have none. The single + regular constraint has 2 parameters and a 2-entry subscripts list. + """ + x = [ + DecisionVariable.binary( + 0, + name="x0", + subscripts=[0], + description="primary slot", + parameters={"role": "primary", "shard": "a"}, + ), + DecisionVariable.binary(1, name="x1", subscripts=[1]), + DecisionVariable.binary(2, name="x2", subscripts=[2]), + ] + c = (x[0] + x[1] + x[2] == 1).set_name("balance").set_subscripts([0, 1]) + c = c.set_parameters({"region": "us-east", "tier": "gold"}) + c = c.set_description("demand-balance row") + assert isinstance(c, Constraint) + return Instance.from_components( + decision_variables=x, + objective=sum(x), + constraints={10: c}, + sense=Instance.MAXIMIZE, + ) + + +# --------------------------------------------------------------------------- +# variable_metadata_df / variable_parameters_df +# --------------------------------------------------------------------------- + + +def test_variable_metadata_df_is_id_indexed_with_columns(): + df = _instance_with_metadata().variable_metadata_df() + assert df.index.name == "variable_id" + assert list(df.index) == [0, 1, 2] + assert {"name", "subscripts", "description"} <= set(df.columns) + assert df.loc[0, "name"] == "x0" + assert df.loc[0, "description"] == "primary slot" + # name is set on every variable in the fixture but description only on 0. + assert pd.isna(df.loc[1, "description"]) + + +def test_variable_parameters_df_long_format_only_emits_present_keys(): + df = _instance_with_metadata().variable_parameters_df() + assert list(df.columns) == ["variable_id", "key", "value"] + # Variable 0 has 2 parameters, 1 and 2 have none. + rows = { + (int(vid), key): val + for vid, key, val in zip(df["variable_id"], df["key"], df["value"]) + } + assert rows == {(0, "role"): "primary", (0, "shard"): "a"} + + +# --------------------------------------------------------------------------- +# constraint_metadata_df / constraint_parameters_df with kind dispatch +# --------------------------------------------------------------------------- + + +def test_constraint_metadata_df_default_kind_is_regular(): + df = _instance_with_metadata().constraint_metadata_df() + assert df.index.name == "regular_constraint_id" + assert list(df.index) == [10] + assert df.loc[10, "name"] == "balance" + assert df.loc[10, "subscripts"] == [0, 1] + assert df.loc[10, "description"] == "demand-balance row" + + +def test_constraint_parameters_df_long_format(): + df = _instance_with_metadata().constraint_parameters_df() + assert list(df.columns) == ["regular_constraint_id", "key", "value"] + rows = { + (int(cid), key): val + for cid, key, val in zip(df["regular_constraint_id"], df["key"], df["value"]) + } + assert rows == {(10, "region"): "us-east", (10, "tier"): "gold"} + + +def test_unknown_kind_raises_value_error(): + instance = _instance_with_metadata() + with pytest.raises(ValueError): + instance.constraint_metadata_df(kind="bogus") + + +def test_each_kind_uses_qualified_index_name(): + """Each constraint family's id column carries a kind-qualified name so + cross-kind joins are visible. Verifies the column / index naming on + indicator / one_hot / sos1 dispatch paths.""" + x = [DecisionVariable.binary(i) for i in range(4)] + instance = Instance.from_components( + decision_variables=x, + objective=sum(x), + constraints={}, + indicator_constraints={ + 5: IndicatorConstraint( + indicator_variable=x[0], + function=x[1] + x[2] - 1, + equality=Equality.EqualToZero, + ) + }, + one_hot_constraints={6: OneHotConstraint(variables=[1, 2, 3])}, + sos1_constraints={7: Sos1Constraint(variables=[0, 1, 2, 3])}, + sense=Instance.MAXIMIZE, + ) + assert ( + instance.constraint_metadata_df(kind="indicator").index.name + == "indicator_constraint_id" + ) + assert ( + instance.constraint_metadata_df(kind="one_hot").index.name + == "one_hot_constraint_id" + ) + assert ( + instance.constraint_metadata_df(kind="sos1").index.name == "sos1_constraint_id" + ) + + +# --------------------------------------------------------------------------- +# constraint_provenance_df is empty on directly-authored constraints +# --------------------------------------------------------------------------- + + +def test_provenance_empty_when_no_chain(): + df = _instance_with_metadata().constraint_provenance_df() + assert df.empty or list(df.columns) == [ + "regular_constraint_id", + "step", + "source_kind", + "source_id", + ] + + +def test_provenance_after_one_hot_conversion(): + """`convert_one_hot_to_constraint` promotes a OneHot row into a regular + constraint; the new constraint records `OneHotConstraint(7)` in its + provenance chain.""" + x = [DecisionVariable.binary(i) for i in range(3)] + instance = Instance.from_components( + decision_variables=x, + objective=sum(x), + constraints={}, + one_hot_constraints={7: OneHotConstraint(variables=[0, 1, 2])}, + sense=Instance.MINIMIZE, + ) + new_id = instance.convert_one_hot_to_constraint(7) + df = instance.constraint_provenance_df() + rows = [ + (int(cid), int(step), src_kind, int(src_id)) + for cid, step, src_kind, src_id in zip( + df["regular_constraint_id"], + df["step"], + df["source_kind"], + df["source_id"], + ) + ] + assert (int(new_id), 0, "OneHotConstraint", 7) in rows + + +# --------------------------------------------------------------------------- +# constraint_removed_reasons_df +# --------------------------------------------------------------------------- + + +def test_removed_reasons_df_after_relax(): + instance = _instance_with_metadata() + instance.relax_constraint(10, "test_reason") + df = instance.constraint_removed_reasons_df() + assert list(df.columns) == [ + "regular_constraint_id", + "reason", + "key", + "value", + ] + # The relax_constraint call provided no extra parameters → 1 row with + # NA key/value. + assert len(df) == 1 + assert int(df["regular_constraint_id"].iloc[0]) == 10 + assert df["reason"].iloc[0] == "test_reason" + key0 = df["key"].iloc[0] + assert ( + key0 is None or (isinstance(key0, float) and math.isnan(key0)) or pd.isna(key0) + ) + + +def test_removed_reasons_df_with_parameters_after_one_hot_conversion(): + """`convert_one_hot_to_constraint` records the conversion reason with a + `constraint_ids` parameter — verifies the long-format expansion of the + parameter map.""" + x = [DecisionVariable.binary(i) for i in range(3)] + instance = Instance.from_components( + decision_variables=x, + objective=sum(x), + constraints={}, + one_hot_constraints={7: OneHotConstraint(variables=[0, 1, 2])}, + sense=Instance.MINIMIZE, + ) + instance.convert_one_hot_to_constraint(7) + df = instance.constraint_removed_reasons_df(kind="one_hot") + assert len(df) == 1 + assert int(df["one_hot_constraint_id"].iloc[0]) == 7 + assert df["reason"].iloc[0] == "ommx.Instance.convert_one_hot_to_constraint" + # The reason carries a single `constraint_id` parameter naming the + # promoted regular constraint id. + assert df["key"].iloc[0] == "constraint_id" + assert isinstance(df["value"].iloc[0], str) + + +# --------------------------------------------------------------------------- +# Solution / SampleSet expose the same surface; sanity-check the parity. +# --------------------------------------------------------------------------- + + +def test_solution_constraint_metadata_df_matches_instance(): + instance = _instance_with_metadata() + sol = instance.evaluate({0: 1, 1: 0, 2: 0}) + df_inst = instance.constraint_metadata_df() + df_sol = sol.constraint_metadata_df() + pd.testing.assert_frame_equal(df_inst, df_sol) + + +def test_sample_set_variable_metadata_df_matches_instance(): + instance = _instance_with_metadata() + ss = instance.evaluate_samples({0: {0: 1, 1: 0, 2: 0}}) + df_inst = instance.variable_metadata_df() + df_ss = ss.variable_metadata_df() + pd.testing.assert_frame_equal(df_inst, df_ss) diff --git a/python/ommx/ommx/_ommx_rust/__init__.pyi b/python/ommx/ommx/_ommx_rust/__init__.pyi index d998b1724..45bf07477 100644 --- a/python/ommx/ommx/_ommx_rust/__init__.pyi +++ b/python/ommx/ommx/_ommx_rust/__init__.pyi @@ -2569,6 +2569,59 @@ class Instance: r""" DataFrame of named functions """ + def constraint_metadata_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint metadata DataFrame (id-indexed wide format). + + One row per constraint id (active + removed) with columns + `name`, `subscripts`, `description`. Index column is + `{kind}_constraint_id`. `kind` selects which constraint family + to read: `"regular"`, `"indicator"`, `"one_hot"`, or `"sos1"`. + """ + def constraint_parameters_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint parameters DataFrame (long format). + + One row per (constraint_id, parameter_key) pair. Columns: + `{kind}_constraint_id`, `key`, `value`. Default RangeIndex. + """ + def constraint_provenance_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint provenance DataFrame (long format). + + One row per (constraint_id, step) pair. Columns: + `{kind}_constraint_id`, `step`, `source_kind`, `source_id`. + """ + def constraint_removed_reasons_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Removed-constraint reasons DataFrame (long format). + + One row per (constraint_id, parameter_key) pair, plus one row with + `key`/`value` set to NA when the reason has no parameters. Columns: + `{kind}_constraint_id`, `reason`, `key`, `value`. + """ + def variable_metadata_df(self) -> pandas.DataFrame: + r""" + Decision-variable metadata DataFrame (id-indexed wide format). + + Columns: `name`, `subscripts`, `description`. Index column = + `variable_id`. + """ + def variable_parameters_df(self) -> pandas.DataFrame: + r""" + Decision-variable parameters DataFrame (long format). + + One row per (variable_id, parameter_key) pair. Columns: + `variable_id`, `key`, `value`. + """ def __copy__(self) -> Instance: ... def __deepcopy__(self, _memo: typing.Any) -> Instance: ... def as_minimization_problem(self) -> builtins.bool: @@ -3347,6 +3400,40 @@ class ParametricInstance: r""" DataFrame of parameters """ + def constraint_metadata_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint metadata DataFrame (id-indexed). See + {meth}`ommx.v1.Instance.constraint_metadata_df` for column / `kind=` + semantics. + """ + def constraint_parameters_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint parameters DataFrame (long format). + """ + def constraint_provenance_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint provenance DataFrame (long format). + """ + def constraint_removed_reasons_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Removed-constraint reasons DataFrame (long format). + """ + def variable_metadata_df(self) -> pandas.DataFrame: + r""" + Decision-variable metadata DataFrame (id-indexed). + """ + def variable_parameters_df(self) -> pandas.DataFrame: + r""" + Decision-variable parameters DataFrame (long format). + """ def __copy__(self) -> ParametricInstance: ... def __deepcopy__(self, _memo: typing.Any) -> ParametricInstance: ... @@ -4144,6 +4231,40 @@ class SampleSet: Static columns: id, used_ids, name, subscripts, description, parameters. Dynamic columns: one per sample_id (int) with the function's evaluated value. """ + def constraint_metadata_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint metadata DataFrame (id-indexed). See + {meth}`ommx.v1.Instance.constraint_metadata_df` for column / `kind=` + semantics. Reads from the sampled collection's metadata store. + """ + def constraint_parameters_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint parameters DataFrame (long format). + """ + def constraint_provenance_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint provenance DataFrame (long format). + """ + def constraint_removed_reasons_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Removed-constraint reasons DataFrame (long format). + """ + def variable_metadata_df(self) -> pandas.DataFrame: + r""" + Decision-variable metadata DataFrame (id-indexed). + """ + def variable_parameters_df(self) -> pandas.DataFrame: + r""" + Decision-variable parameters DataFrame (long format). + """ @typing.final class SampledConstraint: @@ -4730,6 +4851,40 @@ class Solution: Columns: id (index), value, used_ids, name, subscripts, description, parameters.{key} """ + def constraint_metadata_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint metadata DataFrame (id-indexed). See + {meth}`ommx.v1.Instance.constraint_metadata_df` for column / `kind=` + semantics. Reads from the evaluated collection's metadata store. + """ + def constraint_parameters_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint parameters DataFrame (long format). + """ + def constraint_provenance_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Constraint provenance DataFrame (long format). + """ + def constraint_removed_reasons_df( + self, kind: builtins.str = "regular" + ) -> pandas.DataFrame: + r""" + Removed-constraint reasons DataFrame (long format). + """ + def variable_metadata_df(self) -> pandas.DataFrame: + r""" + Decision-variable metadata DataFrame (id-indexed). + """ + def variable_parameters_df(self) -> pandas.DataFrame: + r""" + Decision-variable parameters DataFrame (long format). + """ def __copy__(self) -> Solution: ... def __deepcopy__(self, _memo: typing.Any) -> Solution: ... def total_violation_l1(self) -> builtins.float: diff --git a/python/ommx/src/instance.rs b/python/ommx/src/instance.rs index 9f03a70b4..1645ac3bb 100644 --- a/python/ommx/src/instance.rs +++ b/python/ommx/src/instance.rs @@ -1,5 +1,7 @@ use crate::{ - pandas::{entries_to_dataframe, PyDataFrame}, + pandas::{ + constraint_id_col, entries_to_dataframe, parse_constraint_kind, ConstraintKind, PyDataFrame, + }, Constraint, DecisionVariable, Function, NamedFunction, ParametricInstance, RemovedConstraint, Rng, SampleSet, Samples, Sense, Solution, State, VariableBound, }; @@ -1966,6 +1968,246 @@ impl Instance { entries_to_dataframe(py, self.inner.named_functions().values(), "id", flags) } + /// Constraint metadata DataFrame (id-indexed wide format). + /// + /// One row per constraint id (active + removed) with columns + /// `name`, `subscripts`, `description`. Index column is + /// `{kind}_constraint_id`. `kind` selects which constraint family + /// to read: `"regular"`, `"indicator"`, `"one_hot"`, or `"sos1"`. + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_metadata_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.constraint_collection(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.indicator_constraint_collection(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.one_hot_constraint_collection(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.sos1_constraint_collection(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + } + } + + /// Constraint parameters DataFrame (long format). + /// + /// One row per (constraint_id, parameter_key) pair. Columns: + /// `{kind}_constraint_id`, `key`, `value`. Default RangeIndex. + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_parameters_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.constraint_collection(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.indicator_constraint_collection(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.one_hot_constraint_collection(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.sos1_constraint_collection(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + } + } + + /// Constraint provenance DataFrame (long format). + /// + /// One row per (constraint_id, step) pair. Columns: + /// `{kind}_constraint_id`, `step`, `source_kind`, `source_id`. + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_provenance_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.constraint_collection(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.indicator_constraint_collection(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.one_hot_constraint_collection(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.sos1_constraint_collection(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + } + } + + /// Removed-constraint reasons DataFrame (long format). + /// + /// One row per (constraint_id, parameter_key) pair, plus one row with + /// `key`/`value` set to NA when the reason has no parameters. Columns: + /// `{kind}_constraint_id`, `reason`, `key`, `value`. + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_removed_reasons_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .constraint_collection() + .removed() + .iter() + .map(|(id, (_, r))| (*id, r)), + id_col, + ), + ConstraintKind::Indicator => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .indicator_constraint_collection() + .removed() + .iter() + .map(|(id, (_, r))| (*id, r)), + id_col, + ), + ConstraintKind::OneHot => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .one_hot_constraint_collection() + .removed() + .iter() + .map(|(id, (_, r))| (*id, r)), + id_col, + ), + ConstraintKind::Sos1 => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .sos1_constraint_collection() + .removed() + .iter() + .map(|(id, (_, r))| (*id, r)), + id_col, + ), + } + } + + /// Decision-variable metadata DataFrame (id-indexed wide format). + /// + /// Columns: `name`, `subscripts`, `description`. Index column = + /// `variable_id`. + pub fn variable_metadata_df<'py>(&self, py: Python<'py>) -> PyResult> { + crate::pandas::variable_metadata_dataframe( + py, + self.inner.variable_metadata(), + self.inner.decision_variables().keys().copied(), + "variable_id", + ) + } + + /// Decision-variable parameters DataFrame (long format). + /// + /// One row per (variable_id, parameter_key) pair. Columns: + /// `variable_id`, `key`, `value`. + pub fn variable_parameters_df<'py>( + &self, + py: Python<'py>, + ) -> PyResult> { + crate::pandas::variable_parameters_dataframe( + py, + self.inner.variable_metadata(), + self.inner.decision_variables().keys().copied(), + "variable_id", + ) + } + fn __copy__(&self) -> Self { self.clone() } diff --git a/python/ommx/src/pandas.rs b/python/ommx/src/pandas.rs index 24a4d22be..fd71c6d29 100644 --- a/python/ommx/src/pandas.rs +++ b/python/ommx/src/pandas.rs @@ -2,7 +2,10 @@ //! plus shared helpers for building DataFrames from domain objects. use fnv::FnvHashMap; -use ommx::{ConstraintMetadata, DecisionVariableMetadata, Evaluate, VariableIDSet}; +use ommx::{ + ConstraintMetadata, ConstraintMetadataStore, DecisionVariableMetadata, Evaluate, IDType, + Provenance, RemovedReason, VariableID, VariableIDSet, VariableMetadataStore, +}; use pyo3::{ exceptions::PyValueError, prelude::*, @@ -114,6 +117,247 @@ impl IncludeFlags { const METADATA_KEYS: &[&str] = &["name", "subscripts", "description"]; +// --------------------------------------------------------------------------- +// kind= dispatch — shared by the 4 constraint sidecar accessors +// --------------------------------------------------------------------------- + +/// Constraint family selector for `kind=` arguments on sidecar DataFrames. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ConstraintKind { + Regular, + Indicator, + OneHot, + Sos1, +} + +/// Parse the `kind=` string argument. Returns `ValueError` on unknown values. +pub fn parse_constraint_kind(kind: &str) -> PyResult { + match kind { + "regular" => Ok(ConstraintKind::Regular), + "indicator" => Ok(ConstraintKind::Indicator), + "one_hot" => Ok(ConstraintKind::OneHot), + "sos1" => Ok(ConstraintKind::Sos1), + other => Err(PyValueError::new_err(format!( + "unknown constraint kind: {other:?} (expected one of \"regular\", \"indicator\", \"one_hot\", \"sos1\")" + ))), + } +} + +/// Index column name for the chosen constraint kind. Each kind has a +/// distinct ID space, so the qualified name keeps cross-kind joins +/// visible (`regular_constraint_id` ≠ `indicator_constraint_id` etc.). +pub fn constraint_id_col(kind: ConstraintKind) -> &'static str { + match kind { + ConstraintKind::Regular => "regular_constraint_id", + ConstraintKind::Indicator => "indicator_constraint_id", + ConstraintKind::OneHot => "one_hot_constraint_id", + ConstraintKind::Sos1 => "sos1_constraint_id", + } +} + +// --------------------------------------------------------------------------- +// Sidecar DataFrame builders +// +// Long-format / id-indexed views over the SoA metadata stores. Each builder +// reads the store directly and produces a DataFrame with a documented column +// schema. Used by the `*_metadata_df`, `*_parameters_df`, `*_provenance_df`, +// and `*_removed_reasons_df` accessors on Instance / ParametricInstance / +// Solution / SampleSet. +// --------------------------------------------------------------------------- + +/// Wide id-indexed metadata DataFrame for constraints. +/// +/// One row per id from `ids`, in iteration order. Columns: `name`, +/// `subscripts`, `description`. Index column = `id_col`. +pub fn constraint_metadata_dataframe<'py, ID>( + py: Python<'py>, + store: &ConstraintMetadataStore, + ids: impl Iterator, + id_col: &str, +) -> PyResult> +where + ID: IDType + Into, +{ + let entries: Vec> = ids + .map(|id| -> PyResult<_> { + let dict = PyDict::new(py); + dict.set_item(id_col, Into::::into(id))?; + set_metadata( + &dict, + store.name(id), + store.subscripts(id), + store.description(id), + )?; + Ok(dict.into_any()) + }) + .collect::>()?; + raw_entries_to_dataframe(py, entries, id_col) +} + +/// Long-format parameters DataFrame for constraints. +/// +/// One row per (id, key) pair where `store.parameters(id)` is non-empty. +/// Columns: `id_col`, `key`, `value`. Default RangeIndex (no `set_index`). +pub fn constraint_parameters_dataframe<'py, ID>( + py: Python<'py>, + store: &ConstraintMetadataStore, + ids: impl Iterator, + id_col: &str, +) -> PyResult> +where + ID: IDType + Into, +{ + let mut entries: Vec> = Vec::new(); + for id in ids { + let params = store.parameters(id); + for (key, value) in params { + let dict = PyDict::new(py); + dict.set_item(id_col, Into::::into(id))?; + dict.set_item("key", key)?; + dict.set_item("value", value)?; + entries.push(dict.into_any()); + } + } + long_format_dataframe(py, entries) +} + +/// Long-format provenance DataFrame for constraints. +/// +/// One row per (id, step) pair where `store.provenance(id)` is non-empty. +/// Columns: `id_col`, `step` (0-based), `source_kind` +/// (`"IndicatorConstraint"` / `"OneHotConstraint"` / `"Sos1Constraint"`), +/// `source_id`. Default RangeIndex. +pub fn constraint_provenance_dataframe<'py, ID>( + py: Python<'py>, + store: &ConstraintMetadataStore, + ids: impl Iterator, + id_col: &str, +) -> PyResult> +where + ID: IDType + Into, +{ + let mut entries: Vec> = Vec::new(); + for id in ids { + for (step, p) in store.provenance(id).iter().enumerate() { + let (source_kind, source_id) = provenance_parts(p); + let dict = PyDict::new(py); + dict.set_item(id_col, Into::::into(id))?; + dict.set_item("step", step as u64)?; + dict.set_item("source_kind", source_kind)?; + dict.set_item("source_id", source_id)?; + entries.push(dict.into_any()); + } + } + long_format_dataframe(py, entries) +} + +/// Long-format removed-reasons DataFrame for constraints. +/// +/// One row per (id, parameter_key) pair when the removed reason has +/// parameters; ids without parameters get one row with `key`/`value` set to +/// `pandas.NA`. Columns: `id_col`, `reason`, `key`, `value`. Default +/// RangeIndex. +pub fn constraint_removed_reasons_dataframe<'py, 'a, ID>( + py: Python<'py>, + removed: impl Iterator, + id_col: &str, +) -> PyResult> +where + ID: IDType + Into, +{ + let na = get_na(py)?; + let mut entries: Vec> = Vec::new(); + for (id, reason) in removed { + if reason.parameters.is_empty() { + let dict = PyDict::new(py); + dict.set_item(id_col, Into::::into(id))?; + dict.set_item("reason", &reason.reason)?; + dict.set_item("key", &na)?; + dict.set_item("value", &na)?; + entries.push(dict.into_any()); + } else { + for (key, value) in &reason.parameters { + let dict = PyDict::new(py); + dict.set_item(id_col, Into::::into(id))?; + dict.set_item("reason", &reason.reason)?; + dict.set_item("key", key)?; + dict.set_item("value", value)?; + entries.push(dict.into_any()); + } + } + } + long_format_dataframe(py, entries) +} + +/// Wide id-indexed metadata DataFrame for decision variables. +/// +/// Identical column shape to [`constraint_metadata_dataframe`], reading from +/// a [`VariableMetadataStore`] instead. +pub fn variable_metadata_dataframe<'py>( + py: Python<'py>, + store: &VariableMetadataStore, + ids: impl Iterator, + id_col: &str, +) -> PyResult> { + let entries: Vec> = ids + .map(|id| -> PyResult<_> { + let dict = PyDict::new(py); + dict.set_item(id_col, Into::::into(id))?; + set_metadata( + &dict, + store.name(id), + store.subscripts(id), + store.description(id), + )?; + Ok(dict.into_any()) + }) + .collect::>()?; + raw_entries_to_dataframe(py, entries, id_col) +} + +/// Long-format parameters DataFrame for decision variables. +pub fn variable_parameters_dataframe<'py>( + py: Python<'py>, + store: &VariableMetadataStore, + ids: impl Iterator, + id_col: &str, +) -> PyResult> { + let mut entries: Vec> = Vec::new(); + for id in ids { + let params = store.parameters(id); + for (key, value) in params { + let dict = PyDict::new(py); + dict.set_item(id_col, Into::::into(id))?; + dict.set_item("key", key)?; + dict.set_item("value", value)?; + entries.push(dict.into_any()); + } + } + long_format_dataframe(py, entries) +} + +fn provenance_parts(p: &Provenance) -> (&'static str, u64) { + match *p { + Provenance::IndicatorConstraint(id) => ("IndicatorConstraint", id.into()), + Provenance::OneHotConstraint(id) => ("OneHotConstraint", id.into()), + Provenance::Sos1Constraint(id) => ("Sos1Constraint", id.into()), + } +} + +/// Build a long-format DataFrame from pre-built entry dicts. +/// +/// No `set_index` call, so the DataFrame keeps its default RangeIndex. +/// Used by the `*_parameters_df` / `*_provenance_df` / +/// `*_removed_reasons_df` builders. +fn long_format_dataframe<'py>( + py: Python<'py>, + entries: Vec>, +) -> PyResult> { + let pandas = py.import("pandas")?; + let df = pandas.call_method1("DataFrame", (entries,))?; + df.cast_into().map_err(Into::into) +} + /// Drop columns from a per-row dict according to the include flags. /// /// `metadata` columns are dropped by name; `parameters.*` columns are diff --git a/python/ommx/src/parametric_instance.rs b/python/ommx/src/parametric_instance.rs index 07a3f268d..63c8b06cc 100644 --- a/python/ommx/src/parametric_instance.rs +++ b/python/ommx/src/parametric_instance.rs @@ -1,5 +1,7 @@ use crate::{ - pandas::{entries_to_dataframe, PyDataFrame}, + pandas::{ + constraint_id_col, entries_to_dataframe, parse_constraint_kind, ConstraintKind, PyDataFrame, + }, Constraint, DecisionVariable, Function, Instance, NamedFunction, Parameter, RemovedConstraint, Sense, }; @@ -408,6 +410,227 @@ impl ParametricInstance { entries_to_dataframe(py, self.inner.parameters().values(), "id", flags) } + /// Constraint metadata DataFrame (id-indexed). See + /// {meth}`ommx.v1.Instance.constraint_metadata_df` for column / `kind=` + /// semantics. + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_metadata_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.constraint_collection(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.indicator_constraint_collection(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.one_hot_constraint_collection(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.sos1_constraint_collection(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + } + } + + /// Constraint parameters DataFrame (long format). + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_parameters_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.constraint_collection(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.indicator_constraint_collection(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.one_hot_constraint_collection(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.sos1_constraint_collection(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + } + } + + /// Constraint provenance DataFrame (long format). + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_provenance_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.constraint_collection(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.indicator_constraint_collection(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.one_hot_constraint_collection(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.sos1_constraint_collection(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + } + } + + /// Removed-constraint reasons DataFrame (long format). + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_removed_reasons_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .constraint_collection() + .removed() + .iter() + .map(|(id, (_, r))| (*id, r)), + id_col, + ), + ConstraintKind::Indicator => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .indicator_constraint_collection() + .removed() + .iter() + .map(|(id, (_, r))| (*id, r)), + id_col, + ), + ConstraintKind::OneHot => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .one_hot_constraint_collection() + .removed() + .iter() + .map(|(id, (_, r))| (*id, r)), + id_col, + ), + ConstraintKind::Sos1 => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .sos1_constraint_collection() + .removed() + .iter() + .map(|(id, (_, r))| (*id, r)), + id_col, + ), + } + } + + /// Decision-variable metadata DataFrame (id-indexed). + pub fn variable_metadata_df<'py>(&self, py: Python<'py>) -> PyResult> { + crate::pandas::variable_metadata_dataframe( + py, + self.inner.variable_metadata(), + self.inner.decision_variables().keys().copied(), + "variable_id", + ) + } + + /// Decision-variable parameters DataFrame (long format). + pub fn variable_parameters_df<'py>( + &self, + py: Python<'py>, + ) -> PyResult> { + crate::pandas::variable_parameters_dataframe( + py, + self.inner.variable_metadata(), + self.inner.decision_variables().keys().copied(), + "variable_id", + ) + } + fn __copy__(&self) -> Self { self.clone() } diff --git a/python/ommx/src/sample_set.rs b/python/ommx/src/sample_set.rs index b7fa8d7eb..fd8c1f800 100644 --- a/python/ommx/src/sample_set.rs +++ b/python/ommx/src/sample_set.rs @@ -1,5 +1,8 @@ use crate::{ - pandas::{entries_to_dataframe, sorted_entries_to_dataframe, PyDataFrame, WithSampleIds}, + pandas::{ + constraint_id_col, entries_to_dataframe, parse_constraint_kind, + sorted_entries_to_dataframe, ConstraintKind, PyDataFrame, WithSampleIds, + }, Solution, }; use anyhow::Result; @@ -899,6 +902,263 @@ impl SampleSet { flags, ) } + + /// Constraint metadata DataFrame (id-indexed). See + /// {meth}`ommx.v1.Instance.constraint_metadata_df` for column / `kind=` + /// semantics. Reads from the sampled collection's metadata store. + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_metadata_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.constraints(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.indicator_constraints(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.one_hot_constraints(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.sos1_constraints(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + } + } + + /// Constraint parameters DataFrame (long format). + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_parameters_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.constraints(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.indicator_constraints(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.one_hot_constraints(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.sos1_constraints(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + } + } + + /// Constraint provenance DataFrame (long format). + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_provenance_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.constraints(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.indicator_constraints(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.one_hot_constraints(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.sos1_constraints(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + } + } + + /// Removed-constraint reasons DataFrame (long format). + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_removed_reasons_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .constraints() + .removed_reasons() + .iter() + .map(|(id, r)| (*id, r)), + id_col, + ), + ConstraintKind::Indicator => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .indicator_constraints() + .removed_reasons() + .iter() + .map(|(id, r)| (*id, r)), + id_col, + ), + ConstraintKind::OneHot => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .one_hot_constraints() + .removed_reasons() + .iter() + .map(|(id, r)| (*id, r)), + id_col, + ), + ConstraintKind::Sos1 => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .sos1_constraints() + .removed_reasons() + .iter() + .map(|(id, r)| (*id, r)), + id_col, + ), + } + } + + /// Decision-variable metadata DataFrame (id-indexed). + pub fn variable_metadata_df<'py>(&self, py: Python<'py>) -> PyResult> { + crate::pandas::variable_metadata_dataframe( + py, + self.inner.variable_metadata(), + self.inner.decision_variables().keys().copied(), + "variable_id", + ) + } + + /// Decision-variable parameters DataFrame (long format). + pub fn variable_parameters_df<'py>( + &self, + py: Python<'py>, + ) -> PyResult> { + crate::pandas::variable_parameters_dataframe( + py, + self.inner.variable_metadata(), + self.inner.decision_variables().keys().copied(), + "variable_id", + ) + } } fn sorted_sample_ids(inner: &ommx::SampleSet) -> Vec { diff --git a/python/ommx/src/solution.rs b/python/ommx/src/solution.rs index 0dee1ee9f..fc6ebee87 100644 --- a/python/ommx/src/solution.rs +++ b/python/ommx/src/solution.rs @@ -1,4 +1,6 @@ -use crate::pandas::{entries_to_dataframe, PyDataFrame}; +use crate::pandas::{ + constraint_id_col, entries_to_dataframe, parse_constraint_kind, ConstraintKind, PyDataFrame, +}; use anyhow::Result; use pyo3::{ exceptions::PyKeyError, @@ -778,6 +780,263 @@ impl Solution { ) } + /// Constraint metadata DataFrame (id-indexed). See + /// {meth}`ommx.v1.Instance.constraint_metadata_df` for column / `kind=` + /// semantics. Reads from the evaluated collection's metadata store. + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_metadata_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.evaluated_constraints(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.evaluated_indicator_constraints(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.evaluated_one_hot_constraints(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.evaluated_sos1_constraints(); + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + } + } + + /// Constraint parameters DataFrame (long format). + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_parameters_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.evaluated_constraints(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.evaluated_indicator_constraints(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.evaluated_one_hot_constraints(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.evaluated_sos1_constraints(); + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + } + } + + /// Constraint provenance DataFrame (long format). + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_provenance_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => { + let coll = self.inner.evaluated_constraints(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Indicator => { + let coll = self.inner.evaluated_indicator_constraints(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::OneHot => { + let coll = self.inner.evaluated_one_hot_constraints(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + ConstraintKind::Sos1 => { + let coll = self.inner.evaluated_sos1_constraints(); + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner() + .keys() + .chain(coll.removed_reasons().keys()) + .copied(), + id_col, + ) + } + } + } + + /// Removed-constraint reasons DataFrame (long format). + #[pyo3(signature = (kind = String::from("regular")))] + pub fn constraint_removed_reasons_df<'py>( + &self, + py: Python<'py>, + kind: String, + ) -> PyResult> { + let kind = parse_constraint_kind(&kind)?; + let id_col = constraint_id_col(kind); + match kind { + ConstraintKind::Regular => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .evaluated_constraints() + .removed_reasons() + .iter() + .map(|(id, r)| (*id, r)), + id_col, + ), + ConstraintKind::Indicator => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .evaluated_indicator_constraints() + .removed_reasons() + .iter() + .map(|(id, r)| (*id, r)), + id_col, + ), + ConstraintKind::OneHot => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .evaluated_one_hot_constraints() + .removed_reasons() + .iter() + .map(|(id, r)| (*id, r)), + id_col, + ), + ConstraintKind::Sos1 => crate::pandas::constraint_removed_reasons_dataframe( + py, + self.inner + .evaluated_sos1_constraints() + .removed_reasons() + .iter() + .map(|(id, r)| (*id, r)), + id_col, + ), + } + } + + /// Decision-variable metadata DataFrame (id-indexed). + pub fn variable_metadata_df<'py>(&self, py: Python<'py>) -> PyResult> { + crate::pandas::variable_metadata_dataframe( + py, + self.inner.variable_metadata(), + self.inner.decision_variables().keys().copied(), + "variable_id", + ) + } + + /// Decision-variable parameters DataFrame (long format). + pub fn variable_parameters_df<'py>( + &self, + py: Python<'py>, + ) -> PyResult> { + crate::pandas::variable_parameters_dataframe( + py, + self.inner.variable_metadata(), + self.inner.decision_variables().keys().copied(), + "variable_id", + ) + } + fn __copy__(&self) -> Self { self.clone() } From a458313f77fdf4594562fa9afaa223e293f498a5 Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 14:56:49 +0900 Subject: [PATCH 04/11] docs(metadata-storage-v3): reflect Wave 1.5 (PR #846) landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the proposal doc to mark `include=` and the long-format sidecar dataframes as landed, leaving only the Series accessors, the two-mode Attached wrappers, and the `kind=Literal[...]` consolidation as deferred to Wave 2. Specifically: - Status line: split out Wave 1.5 from the deferred bucket. - Python target shape: replace "*_df still v2-style; include=/sidecars deferred" with the actual landed shape (`*_df()` is a method, `include=Sequence[str]`, six sidecar accessors). - Resolved decisions: clarify that `kind=` is `str` today (not `Literal[...]`), and that `include=("removed_reasons",)` is not part of the landed include= surface. - Avoiding cross-ID-space joins: distinguish the qualified index names that already ship on sidecars from the wide-`*_df` rename bundled with the deferred consolidation. - ToPandasEntry restructuring: add the post-filter / sidecar-builder paths landed in PR #846. - Breaking changes: new "Landed in PR #846" subsection covering the property → method conversion plus include= and sidecar shapes; drop the include=/sidecar bullets from "Deferred to follow-up wave". - Follow-ups: add the `kind=Literal[...]` + `@overload` consolidation and the `include=("removed_reasons",)` pivot as explicit deferred items. Co-Authored-By: Claude Opus 4.7 (1M context) --- METADATA_STORAGE_V3.md | 296 +++++++++++++++++++++++++---------------- 1 file changed, 185 insertions(+), 111 deletions(-) diff --git a/METADATA_STORAGE_V3.md b/METADATA_STORAGE_V3.md index 2fb464f7d..2b4e116e1 100644 --- a/METADATA_STORAGE_V3.md +++ b/METADATA_STORAGE_V3.md @@ -1,7 +1,8 @@ # Metadata Storage in OMMX v3 — Design Proposal Status: **Rust SDK landed; Python wrappers landed (snapshot model); -Series + `include=` + sidecar dfs deferred to follow-up PRs** +`include=` + long-format sidecar dfs landed; Series accessors and +two-mode Attached wrappers deferred to follow-up PRs** This proposal is a **prerequisite** for `SPECIAL_CONSTRAINTS_V3.md` (PR #841). The proto-schema redesign in #841 cannot be finalized without first deciding @@ -14,7 +15,7 @@ discussion was split out of #841 and runs first. This is a single connected redesign covering Rust SDK runtime layout and Python SDK API surface. The document describes the target shape; phasing of the implementation across PRs is decided in the implementation issues, not -here. The implementation actually shipped in two waves: +here. The implementation shipped in three waves: - **Wave 1 (PR #843, landed):** Rust SDK SoA stores at the collection layer, parse / serialize boundary moved to per-collection, per-element @@ -25,12 +26,22 @@ here. The implementation actually shipped in two waves: store, `from_components` drains snapshots back). `*_df` rendering kept working via a `WithMetadata<'a, T, M>` wrapper inside `pandas.rs` — call sites pre-snapshot the SoA and zip it alongside each item. -- **Wave 2 (deferred):** `Series[ID -> Object]` accessors, `*_df` - `include=` parameter, long-format sidecar dfs - (`constraint_metadata_df` / `constraint_parameters_df` / - `constraint_provenance_df` / `constraint_removed_reasons_df` / - `variable_metadata_df` / `variable_parameters_df`), and the two-mode - Standalone / Attached wrappers with `Py` back-references. +- **Wave 1.5 (PR #846, landed):** `include=` parameter on every wide + `*_df` accessor (default preserves the v2 wide shape; `include=[]` + drops metadata + parameters columns). Long-format sidecar dataframes + on Instance / ParametricInstance / Solution / SampleSet, reading + directly from the SoA stores: `constraint_metadata_df(kind=...)`, + `constraint_parameters_df(kind=...)`, + `constraint_provenance_df(kind=...)`, + `constraint_removed_reasons_df(kind=...)`, + `variable_metadata_df()`, `variable_parameters_df()`. Mechanical + v3-alpha breaking change: every `*_df` accessor is now a method + (`instance.constraints_df` → `instance.constraints_df()`). +- **Wave 2 (deferred):** `Series[ID -> Object]` collection accessors, + the two-mode Standalone / Attached wrappers with `Py` + back-references and write-through metadata setters, and the + `kind=Literal[...]` consolidation that collapses today's per-kind + `*_constraints_df` family into one `constraints_df(kind=...)`. Sections below mark each item with **(landed)** or **(deferred)** so it is clear which parts are live in v3 alpha and which are still proposal. @@ -115,11 +126,23 @@ SoA store; the behavior they implement is unchanged. - `instance.constraints`, `decision_variables`, `*_constraints` are still `dict` / `list` of wrapper objects. The proposed `pandas.Series[ID -> Object]` shape is **deferred** to a follow-up PR. -- `*_df` methods still take the v2-style positional shape. The proposed - `include=` parameter and the long-format sidecar dfs are **deferred** - to a follow-up PR. Internally the rendering path now reads metadata - from the SoA store via a `WithMetadata<'a, T, M>` wrapper, so the - switch to the new shape is mechanical. +- `*_df` accessors are now methods (not `#[getter]` properties), each + with an `include=` parameter that gates the metadata / parameters + column families. Default `include=("metadata", "parameters")` + preserves the v2 wide shape; `include=[]` drops both, single-element + forms keep the named family. **(landed in PR #846)** Internally the + rendering path uses a `WithMetadata<'a, T, M>` wrapper to pair items + with snapshots; a post-filter in `entries_to_dataframe` drops the + gated columns before the DataFrame is built. +- Six long-format / id-indexed sidecar dataframes read directly from + the SoA stores: `constraint_metadata_df(kind=...)`, + `constraint_parameters_df(kind=...)`, + `constraint_provenance_df(kind=...)`, + `constraint_removed_reasons_df(kind=...)`, + `variable_metadata_df()`, `variable_parameters_df()`. Index column + names are kind-qualified (`regular_constraint_id` ≠ + `indicator_constraint_id`) to keep cross-id-space mistakes visible. + **(landed in PR #846)** ### Proto @@ -516,10 +539,13 @@ or use the Rust SoA setters. The two-mode design described next is the long-term replacement for the snapshot model — it would let `c.set_name("x")` write through to the instance's SoA store. Adopting it requires `Py` back-references -on every wrapper and is gated behind the deferred Series / `include=` -work. +on every wrapper and is gated behind the deferred Series accessor work. + +### Layered views over the Rust SoA store **(partially landed)** -### Layered views over the Rust SoA store **(deferred)** +`include=` and the long-format sidecar dfs are landed (PR #846); the +Series accessor and the `kind=Literal` consolidation of per-kind +`*_constraints_df` remain deferred. ``` Rust SoA store (canonical) @@ -678,43 +704,38 @@ indexing, `.items()`, `.index`. Operations users lose vs. dict: The right answer is `instance.constraints_df()["equality"]`, which is bulk-built from the SoA. Document this; do not enforce. -### `*_df` methods → derived views with `include` **(deferred)** +### `*_df` methods → derived views with `include` **(landed in PR #846; `kind=` consolidation deferred)** Each `*_df` is a derived view: type-specific core columns extracted from the SoA store, plus whichever sidecars the caller asks for via `include`. The default `include` matches v2's wide-DataFrame shape -(`metadata` + `parameters`), so v2 user code keeps working with only -a `kind=...` argument added. +(`metadata` + `parameters`), so v2 user code keeps working unchanged +on the per-kind methods that exist today. ```python -# === v2-style wide DataFrame (default include) === -df = instance.constraints_df(kind="regular") -# ≡ instance.constraints_df(kind="regular", include=("metadata", "parameters")) -# index name = regular_constraint_id +# === v2-equivalent wide DataFrame (default include) — landed === +df = instance.constraints_df() +# ≡ instance.constraints_df(include=("metadata", "parameters")) # columns: equality, function_type, used_ids, # name, subscripts, description, parameters.{key}, ... +# (Note: today's per-kind `constraints_df` / `indicator_constraints_df` +# / `one_hot_constraints_df` / `sos1_constraints_df` family is kept +# intact in PR #846; the `kind=Literal[...]` consolidation that +# collapses these into one is deferred to a follow-up PR.) df = instance.decision_variables_df() # ≡ instance.decision_variables_df(include=("metadata", "parameters")) -# index name = variable_id # columns: kind, lower, upper, substituted_value, # name, subscripts, description, parameters.{key}, ... -# === Core only — pass include=() === -df = instance.constraints_df(kind="regular", include=()) +# === Core only — pass include=[] (landed) === +df = instance.constraints_df(include=[]) # columns: equality, function_type, used_ids -df = instance.decision_variables_df(include=()) +df = instance.decision_variables_df(include=[]) # columns: kind, lower, upper, substituted_value -# === Add removed_reasons (not in v2 default) === -df = instance.constraints_df( - kind="regular", - include=("metadata", "parameters", "removed_reasons"), -) -# … plus removed_reasons.reason, removed_reasons.{key} - -# === Long-format sidecars when wide pivoting isn't what you want === +# === Long-format sidecars (landed in PR #846) === meta = instance.constraint_metadata_df(kind="regular") # index name=regular_constraint_id; # name, subscripts, description @@ -726,14 +747,26 @@ params = instance.constraint_parameters_df(kind="regular") removed = instance.constraint_removed_reasons_df(kind="regular") # columns: regular_constraint_id, reason, # key, value + +# Variable-side sidecars omit the kind= dispatch (one ID space): +vmeta = instance.variable_metadata_df() +vparams = instance.variable_parameters_df() ``` -`include` accepts a tuple of `Literal["metadata","parameters","removed_reasons"]` -values. `provenance` is intentionally absent from `include`: chains -have variable length, so a wide pivot would either explode the column -space (`provenance.0.*`, `provenance.1.*`, …) or produce an -object-dtype list column. Users who want provenance pivot the long- -format `constraint_provenance_df()` themselves. +The shipping `include=` accepts `Sequence[str]` containing +`"metadata"` and/or `"parameters"`. Unknown values raise +`ValueError`. The originally-proposed `"removed_reasons"` flag is +**not** part of the landed shape — folding removed reasons into the +active wide df would require unifying the active + removed iteration +paths, which is deferred along with the `kind=` consolidation. Users +who want removed-reason data go through the dedicated +`constraint_removed_reasons_df(kind=...)` long-format sidecar. + +`provenance` is intentionally absent from `include` for the long-term +shape too: chains have variable length, so a wide pivot would either +explode the column space (`provenance.0.*`, `provenance.1.*`, …) or +produce an object-dtype list column. Users who want provenance pivot +the long-format `constraint_provenance_df()` themselves. `Solution` and `SampleSet` expose the same `*_df` family with stage- appropriate core-column schemas and the same `include` parameter: @@ -768,7 +801,7 @@ call when they want individual wrapper objects; the wrapper getters are what they call when they already hold one wrapper. Four surfaces, one canonical store. -### Avoiding cross-ID-space joins **(deferred — applies to the Series / `include=` shape)** +### Avoiding cross-ID-space joins **(partially landed)** Each constraint kind has its own ID space (regular ID 5 ≠ indicator ID 5), and decision variable IDs live in yet another space. With every df sharing @@ -776,8 +809,9 @@ an `int64` index, `df.join()` between mismatched-kind dfs would silently produce an incorrect-but-shaped result. We ward off that mistake at the naming and helper layers: -1. **Distinct index names per ID space.** All dfs returned by the API set - their index name to a kind-qualified label: +1. **Distinct index names per ID space (landed in PR #846 for sidecars).** + The new long-format / id-indexed sidecar dfs set their index column + to a kind-qualified label: ``` variable_id # decision variables @@ -787,42 +821,40 @@ naming and helper layers: sos1_constraint_id # SOS1 constraints ``` + The wide per-kind `*_df` accessors still index by the unqualified + `id` column (a v2 carry-over). Renaming those is bundled with the + `kind=Literal[...]` consolidation that turns + `indicator_constraints_df()` etc. into `constraints_df(kind=...)`, + and is therefore deferred along with that work. + pandas `df.join(other)` aligns by index but does *not* enforce that the index names match. The qualified names alone won't stop a wrong join — but they're visible in `df.head()`, `df.info()`, IDE inspection, and migration-guide examples, so users see the mismatch immediately rather than chasing a silent bug downstream. -2. **`include=` covers the common "wide" case without manual join.** - The `*_df` methods themselves accept an `include` parameter that - folds sidecars into the result, so most users never write a - `df.join(other_df)` at all: +2. **`include=` covers the common "wide" case without manual join + (landed).** The wide `*_df` methods accept an `include` parameter + that gates the metadata / parameters column families, so most users + never write a `df.join(other_df)` at all: ```python - instance.constraints_df(kind="regular") + instance.constraints_df() # default include=("metadata","parameters") — v2-equivalent shape - - instance.constraints_df(kind="regular", - include=("metadata","parameters","removed_reasons")) - # core + metadata + pivoted parameters + pivoted removed_reasons ``` - `include` is a tuple of literal strings (typed via `Literal[...]`). - `"metadata"` is left-joined as columns; `"parameters"` and - `"removed_reasons"` are left-joined after pivoting their long- - format keys back to wide columns under namespaced prefixes - (`parameters.{key}`, `removed_reasons.{key}`). Cross-ID-space - mistakes are impossible inside `include=` because the helper - knows the right kind to look up internally. Users who need a - manual cross-df `join` go through the long-format sidecars, - where the qualified index names (point 1) make the mistake - visible. + `include` is a `Sequence[str]` containing `"metadata"` and/or + `"parameters"`. The originally-proposed `"removed_reasons"` flag is + not part of the landed shape — for removed-reason data, users go + through `constraint_removed_reasons_df(kind=...)` (long format), + where the qualified index name (point 1) makes the kind explicit. 3. **Wrapper-object access stays the safest path for single-id - lookups.** `s.loc[id].name` reads metadata via the back-reference - without any join, so any code that operates a constraint at a time - sidesteps the issue entirely. `*_df` joins are reserved for bulk - analysis, where (1) and (2) cover the realistic mistake modes. + lookups.** `instance.constraints[id].name` reads metadata directly + off the snapshot wrapper without any join, so any code that + operates a constraint at a time sidesteps the issue entirely. + `*_df` joins are reserved for bulk analysis, where (1) and (2) + cover the realistic mistake modes. A stronger guarantee — encoding kind in the index dtype itself (MultiIndex `(kind, id)`, or a custom ExtensionArray) — was considered @@ -831,28 +863,34 @@ ExtensionArray is a meaningful pandas integration and a maintenance burden disproportionate to the failure mode it prevents. The qualified index name + `include=` covering the common wide case are sufficient. -### `ToPandasEntry` restructuring **(landed for snapshot model; deferred parts noted)** +### `ToPandasEntry` restructuring **(landed for snapshot model + sidecars; Series deferred)** `python/ommx/src/pandas.rs` previously had ~16 `ToPandasEntry` impls that read `self.metadata.X` directly off each element. The shipping shape is: -- **Core + metadata dfs (landed).** The `ToPandasEntry` trait stays. - Every impl that needed metadata is rewritten to consume +- **Core + metadata dfs (landed in PR #843).** The `ToPandasEntry` + trait stays. Every impl that needed metadata is rewritten to consume `WithMetadata<'_, T, ConstraintMetadata | DecisionVariableMetadata>` rather than the bare element. Call sites in Instance / ParametricInstance / Solution / SampleSet pre-snapshot the SoA store, build a `Vec<(metadata, item)>`, and zip the snapshot in via `WithMetadata::new` before passing the iterator to - `entries_to_dataframe`. The user-visible DataFrame shape is - unchanged from v2. -- **Metadata dfs as bulk column-wise builders (deferred).** The - proposed `metadata_store_to_dataframe(store)` that walks fields - and emits one column allocation per field is part of the long- - format sidecar work and lands with the Series / `include=` PR. -- **Long-format dfs (deferred).** `constraint_parameters_df` / - `constraint_provenance_df` / `constraint_removed_reasons_df` etc. - are not yet exposed. + `entries_to_dataframe`. +- **`include=` filter (landed in PR #846).** `entries_to_dataframe` + takes an `IncludeFlags` and post-filters per-row dicts to drop the + gated columns before the DataFrame is built. The `ToPandasEntry` + impls themselves are unchanged — they still emit every column + unconditionally; the gating happens one layer up. Trades a small + per-row cost for a near-zero refactor footprint on the impls. +- **Long-format / id-indexed sidecar builders (landed in PR #846).** + `constraint_metadata_dataframe`, `constraint_parameters_dataframe`, + `constraint_provenance_dataframe`, + `constraint_removed_reasons_dataframe`, + `variable_metadata_dataframe`, `variable_parameters_dataframe` + read directly from the SoA stores (via `store.name(id)` / + `.parameters(id)` / `.provenance(id)`) and don't go through + `ToPandasEntry` at all — they're bulk column-wise builders. - **Series accessors (deferred).** The current accessors still return `dict` / `list`; the `pandas.Series[ID -> Object]` shape with Attached wrappers is part of the deferred wave. @@ -909,6 +947,31 @@ shape is: standalone with `set_name(...)` etc. continue to round-trip their metadata through the Instance. +### Landed in PR #846 + +- Every `*_df` accessor on `Instance` / `ParametricInstance` / + `Solution` / `SampleSet` is now a method, not a `#[getter]` + property. Migration: `instance.constraints_df` → + `instance.constraints_df()`. Affects every `*_df` including + `removed_reasons_df` / `*_removed_reasons_df` (those don't take + `include=` since they have no metadata / parameters columns to + filter, but the method-call form is required for API consistency). +- Wide `*_df` methods take `include: Sequence[str] | None = None`. + Default (`None` → `("metadata", "parameters")`) preserves the v2 + wide shape; `include=[]` drops both column families; + `include=["metadata"]` / `include=["parameters"]` keep just the + named family. Unknown values raise `ValueError`. +- New long-format / id-indexed sidecar methods on `Instance`, + `ParametricInstance`, `Solution`, and `SampleSet`: + `constraint_metadata_df(kind=...)`, + `constraint_parameters_df(kind=...)`, + `constraint_provenance_df(kind=...)`, + `constraint_removed_reasons_df(kind=...)`, + `variable_metadata_df()`, `variable_parameters_df()`. `kind` + accepts `"regular"` / `"indicator"` / `"one_hot"` / `"sos1"` + (default `"regular"`); unknown values raise `ValueError`. Index + column names are kind-qualified. + ### Deferred to follow-up wave - `instance.constraints`, `decision_variables`, `*_constraints` change @@ -917,28 +980,11 @@ shape is: - `s.values()` (method call) → `s.tolist()` or `list(s)`. - List-positional reliance on the old `decision_variables: list[…]` ordering breaks; index by `VariableID` instead. -- `*_df` methods (`constraints_df(kind=...)`, - `decision_variables_df()`, and the Solution / SampleSet - counterparts) gain an `include=` parameter selecting which - sidecars to fold in. The default - (`include=("metadata","parameters")`) reproduces the v2 wide- - DataFrame shape, so v2 user code keeps working with only a - `kind=...` argument added — `df["name"]`, - `df["parameters.{key}"]`, etc. continue to resolve. Users who - want only the core columns pass `include=()`. -- New long-format sidecar methods on `Instance`, `Solution`, and - `SampleSet`: `constraint_metadata_df(kind=...)`, - `constraint_parameters_df(kind=...)`, - `constraint_provenance_df(kind=...)`, - `constraint_removed_reasons_df(kind=...)`, - `variable_metadata_df()`, `variable_parameters_df()`. These - expose the SoA store as tidy long-format DataFrames for users - who want pivot / aggregation / cross-instance union beyond what - `include=` covers. - Per-kind `indicator_constraints_df()`, `one_hot_constraints_df()`, `sos1_constraints_df()`, and `removed_*_constraints_df()` collapse into the single - `constraints_df(kind=...)` overload set. + `constraints_df(kind=...)` overload set (with `kind=Literal[...]` + + `pyo3-stub-gen` `@overload` for per-kind column schemas). - Wrapper-object metadata setters become write-through to the SoA store via the Standalone / Attached two-mode design. - `NamedFunction` / `EvaluatedNamedFunction` / @@ -957,19 +1003,24 @@ side in detail once the deferred wave lands. Numbering preserved from the original Open Questions list for traceability with earlier review comments. -1. **Kind dispatch in Python — single method with `Literal` + - `@overload`.** `constraint_metadata_df(kind="regular")` and the - sibling `*_df(kind=...)` family use a `kind: - Literal["regular","indicator","one_hot","sos1"]` parameter rather - than four separate methods. `pyo3-stub-gen` supports emitting - `typing.overload` stubs keyed on `Literal[…]` arguments, so each - kind's overload still advertises its specific column schema in - the IDE / type checker. +1. **Kind dispatch in Python — single method with `kind=...` + parameter.** The new sidecar accessors + (`constraint_metadata_df(kind="regular")` and siblings) take a + `kind: str` argument validated at runtime against + `{"regular", "indicator", "one_hot", "sos1"}`. The originally- + proposed `Literal[...]` typing + `pyo3-stub-gen` `@overload` + stubs are deferred along with the consolidation of today's + per-kind `constraints_df` / `indicator_constraints_df` / + `one_hot_constraints_df` / `sos1_constraints_df` family — when + that consolidation lands, the `Literal` typing covers both the + wide and the sidecar accessors uniformly. 2. **`removed_reason` — separate long-format `constraint_removed_reasons_df(kind=...)`.** `RemovedReason` is collection-level metadata in Rust, not part of the constraint. - Wide pivoting is available on opt-in via - `constraints_df(kind=..., include=("removed_reasons",))`. + It is exposed only via the dedicated long-format sidecar; the + originally-proposed `include=("removed_reasons",)` pivot into + the wide `*_df` is deferred along with the active+removed + unification. 3. **Atomic insert-with-metadata on the Rust side — `insert_with` takes the existing owned `ConstraintMetadata` struct.** The pre- v3 `ConstraintMetadata` (owned struct with `name`, `subscripts`, @@ -1063,7 +1114,7 @@ traceability with earlier review comments. pattern needs revisiting (e.g. weak-handle variant of the wrapper) is a question to take up at implementation time, not before. -## Follow-ups (post-#843) +## Follow-ups (post-#843, post-#846) - **Tighten `ConstraintCollection` mutation surface.** `active_mut()` / `removed_mut()` / `insert_with()` are still `pub` @@ -1073,11 +1124,34 @@ traceability with earlier review comments. `*_metadata_mut`). These three methods should be narrowed to `pub(crate)` (or smaller) in a follow-up so the only way to break the collection's invariants is from inside the crate. +- **`kind=Literal[...]` + `@overload` consolidation.** Today's + per-kind `*_constraints_df` family + (`indicator_constraints_df()` / `one_hot_constraints_df()` / + `sos1_constraints_df()` / `removed_*_constraints_df()`) + collapses into one `constraints_df(kind=...)` overload set, + with the `kind` parameter typed as `Literal[...]` and the + per-kind column schemas surfaced via + `pyo3-stub-gen`-emitted `@overload` stubs. The new sidecar + accessors landed in PR #846 already follow the `kind=...` + shape but still take `kind: str` validated at runtime; this + follow-up tightens the typing on both surfaces in one go and + adds a `_constraint_id` qualified index name on the wide + `*_df` accessors as part of the API rename. +- **`include=("removed_reasons",)` on the wide `*_df`.** + Currently removed-reason data is only available via the + dedicated `constraint_removed_reasons_df(kind=...)` long-format + sidecar. Folding it into `constraints_df` requires unifying the + active + removed iteration paths and is bundled with the + consolidation above. - **`NamedFunction` SoA migration.** Track the `NamedFunctionMetadataStore` work described under "NamedFunction (deferred — separate PR)" above. -- **Python Series / `include=` / sidecar dfs.** Wave 2 of the Python - surface, blocked on the snapshot-vs-attached decision. +- **Python Series + Attached two-mode wrappers.** Wave 2 of the + Python surface — `instance.constraints` returns a + `pandas.Series[ID -> Object]`, wrappers gain a `Py` + back-reference and write-through metadata setters. Blocked on + the snapshot-vs-attached decision; the `Py` lifetime + question is documented in resolved decision #8. ## Open questions From cc7841d8b254d8f634ca47e33430b3c9c6248792 Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 16:15:57 +0900 Subject: [PATCH 05/11] test(pandas): convert dataframe tests to syrupy snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace explicit per-cell assertions on `*_df()` output with snapshot-based checks. The `.ambr` files are the authoritative description of each method's column / index schema under each configuration; update via `pytest --snapshot-update` after a deliberate API change. Behavioral assertions stay explicit where snapshotting doesn't help: - `test_unknown_include_flag_raises_value_error` / `test_unknown_kind_raises_value_error` (ValueError checks). - `test_solution_constraint_metadata_df_matches_instance` / `test_sample_set_variable_metadata_df_matches_instance` (parity via `pd.testing.assert_frame_equal`). Also sort long-format `*_parameters_df` and `*_removed_reasons_df` rows by `(id, key)` in the Rust builder. The upstream `Constraint.set_parameters` accepts `std::HashMap`, whose iteration order is randomized per process — without an explicit sort the snapshot would flap between runs. Sorted output is also more useful for human readers of the DataFrame. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__snapshots__/test_dataframe_include.ambr | 69 +++++++ .../test_dataframe_sidecars.ambr | 77 ++++++++ .../tests/test_dataframe_include.py | 93 +++++----- .../tests/test_dataframe_sidecars.py | 170 +++++++----------- python/ommx/src/pandas.rs | 127 +++++++++---- 5 files changed, 350 insertions(+), 186 deletions(-) create mode 100644 python/ommx-tests/tests/__snapshots__/test_dataframe_include.ambr create mode 100644 python/ommx-tests/tests/__snapshots__/test_dataframe_sidecars.ambr diff --git a/python/ommx-tests/tests/__snapshots__/test_dataframe_include.ambr b/python/ommx-tests/tests/__snapshots__/test_dataframe_include.ambr new file mode 100644 index 000000000..fb76be0d5 --- /dev/null +++ b/python/ommx-tests/tests/__snapshots__/test_dataframe_include.ambr @@ -0,0 +1,69 @@ +# serializer version: 1 +# name: test_constraints_df_default + ''' + equality type used_ids name subscripts description + id + 10 =0 Linear {0, 1, 2} balance [] + ''' +# --- +# name: test_constraints_df_include_empty + ''' + equality type used_ids + id + 10 =0 Linear {0, 1, 2} + ''' +# --- +# name: test_decision_variables_df_default + ''' + kind lower upper name subscripts description substituted_value parameters.shard parameters.role + id + 0 Binary 0.0 1.0 x0 [0] variable 0 0 primary + 1 Binary 0.0 1.0 x1 [1] variable 1 1 primary + 2 Binary 0.0 1.0 x2 [2] variable 2 2 primary + ''' +# --- +# name: test_decision_variables_df_include_empty + ''' + kind lower upper substituted_value + id + 0 Binary 0.0 1.0 + 1 Binary 0.0 1.0 + 2 Binary 0.0 1.0 + ''' +# --- +# name: test_decision_variables_df_include_metadata_only + ''' + kind lower upper name subscripts description substituted_value + id + 0 Binary 0.0 1.0 x0 [0] variable 0 + 1 Binary 0.0 1.0 x1 [1] variable 1 + 2 Binary 0.0 1.0 x2 [2] variable 2 + ''' +# --- +# name: test_decision_variables_df_include_parameters_only + ''' + kind lower upper substituted_value parameters.shard parameters.role + id + 0 Binary 0.0 1.0 0 primary + 1 Binary 0.0 1.0 1 primary + 2 Binary 0.0 1.0 2 primary + ''' +# --- +# name: test_sample_set_decision_variables_df_include_empty + ''' + kind lower upper 0 1 + id + 0 Binary 0.0 1.0 1.0 0.0 + 1 Binary 0.0 1.0 0.0 1.0 + 2 Binary 0.0 1.0 0.0 0.0 + ''' +# --- +# name: test_solution_decision_variables_df_include_empty + ''' + kind lower upper substituted_value value + id + 0 Binary 0.0 1.0 1.0 + 1 Binary 0.0 1.0 0.0 + 2 Binary 0.0 1.0 0.0 + ''' +# --- diff --git a/python/ommx-tests/tests/__snapshots__/test_dataframe_sidecars.ambr b/python/ommx-tests/tests/__snapshots__/test_dataframe_sidecars.ambr new file mode 100644 index 000000000..81cdab7bf --- /dev/null +++ b/python/ommx-tests/tests/__snapshots__/test_dataframe_sidecars.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_constraint_metadata_df_default_kind_is_regular + ''' + name subscripts description + regular_constraint_id + 10 balance [0, 1] demand-balance row + ''' +# --- +# name: test_constraint_parameters_df + ''' + regular_constraint_id key value + 0 10 region us-east + 1 10 tier gold + ''' +# --- +# name: test_indicator_kind_metadata_df + ''' + name subscripts description + indicator_constraint_id + 5 [] + ''' +# --- +# name: test_one_hot_kind_metadata_df + ''' + name subscripts description + one_hot_constraint_id + 6 [] + ''' +# --- +# name: test_provenance_after_one_hot_conversion + ''' + regular_constraint_id step source_kind source_id + 0 0 0 OneHotConstraint 7 + ''' +# --- +# name: test_provenance_empty_when_no_chain + ''' + Empty DataFrame + Columns: [] + Index: [] + ''' +# --- +# name: test_removed_reasons_df_after_relax + ''' + regular_constraint_id reason key value + 0 10 test_reason + ''' +# --- +# name: test_removed_reasons_df_with_parameters_after_one_hot_conversion + ''' + one_hot_constraint_id reason key value + 0 7 ommx.Instance.convert_one_hot_to_constraint constraint_id 0 + ''' +# --- +# name: test_sos1_kind_metadata_df + ''' + name subscripts description + sos1_constraint_id + 7 [] + ''' +# --- +# name: test_variable_metadata_df + ''' + name subscripts description + variable_id + 0 x0 [0] primary slot + 1 x1 [1] + 2 x2 [2] + ''' +# --- +# name: test_variable_parameters_df + ''' + variable_id key value + 0 0 role primary + 1 0 shard a + ''' +# --- diff --git a/python/ommx-tests/tests/test_dataframe_include.py b/python/ommx-tests/tests/test_dataframe_include.py index 5853dbfb7..6339da3f8 100644 --- a/python/ommx-tests/tests/test_dataframe_include.py +++ b/python/ommx-tests/tests/test_dataframe_include.py @@ -1,24 +1,27 @@ """Tests for the `include=` parameter on wide `*_df` methods. Default `include` matches the v2-equivalent wide shape (`("metadata", -"parameters")`); `include=()` drops both metadata and parameter columns; -`include=("metadata",)` and `include=("parameters",)` keep only the named +"parameters")`); `include=[]` drops both metadata and parameter columns; +`include=["metadata"]` and `include=["parameters"]` keep only the named family. + +Most assertions are snapshot-based (syrupy) — the `.ambr` file is the +authoritative description of each method's column shape under each +`include=` setting. Update via `pytest --snapshot-update` after a +deliberate API change. """ from __future__ import annotations +import pandas as pd import pytest from ommx.v1 import ( + Constraint, DecisionVariable, Instance, - Constraint, ) -METADATA_COLS = {"name", "subscripts", "description"} - - def _build_instance() -> Instance: """Instance with metadata + parameters on decision variables and constraints.""" x = [ @@ -41,59 +44,57 @@ def _build_instance() -> Instance: ) +def _df_snap(df: pd.DataFrame) -> str: + """Deterministic, snapshot-friendly rendering of a DataFrame.""" + return df.to_string(na_rep="") + + # --------------------------------------------------------------------------- # decision_variables_df — DV has both metadata and parameters columns # --------------------------------------------------------------------------- -def test_decision_variables_df_default_includes_both(): - instance = _build_instance() - df = instance.decision_variables_df() - assert METADATA_COLS.issubset(df.columns) - assert "parameters.role" in df.columns - assert "parameters.shard" in df.columns +def test_decision_variables_df_default(snapshot): + """Default include=("metadata","parameters") — both column families on.""" + assert _df_snap(_build_instance().decision_variables_df()) == snapshot -def test_decision_variables_df_include_empty_drops_both(): - instance = _build_instance() - df = instance.decision_variables_df(include=[]) - assert METADATA_COLS.isdisjoint(df.columns) - assert not any(c.startswith("parameters.") for c in df.columns) +def test_decision_variables_df_include_empty(snapshot): + """include=[] — metadata + parameters columns dropped, core columns remain.""" + assert _df_snap(_build_instance().decision_variables_df(include=[])) == snapshot -def test_decision_variables_df_include_metadata_only(): - instance = _build_instance() - df = instance.decision_variables_df(include=["metadata"]) - assert METADATA_COLS.issubset(df.columns) - assert not any(c.startswith("parameters.") for c in df.columns) +def test_decision_variables_df_include_metadata_only(snapshot): + """include=["metadata"] — name/subscripts/description kept, parameters.* dropped.""" + assert ( + _df_snap(_build_instance().decision_variables_df(include=["metadata"])) + == snapshot + ) -def test_decision_variables_df_include_parameters_only(): - instance = _build_instance() - df = instance.decision_variables_df(include=["parameters"]) - assert METADATA_COLS.isdisjoint(df.columns) - assert "parameters.role" in df.columns - assert "parameters.shard" in df.columns +def test_decision_variables_df_include_parameters_only(snapshot): + """include=["parameters"] — parameters.* kept, name/subscripts/description dropped.""" + assert ( + _df_snap(_build_instance().decision_variables_df(include=["parameters"])) + == snapshot + ) # --------------------------------------------------------------------------- # constraints_df — Constraint has only metadata columns; parameters family -# is currently not emitted, so include=("parameters",) is a no-op. +# is currently not emitted by the wide *_df, so include=("parameters",) is +# a no-op there. (The constraint's own `parameters` map is exposed only +# via the long-format `constraint_parameters_df` sidecar.) # --------------------------------------------------------------------------- -def test_constraints_df_default_emits_metadata(): - instance = _build_instance() - df = instance.constraints_df() - assert METADATA_COLS.issubset(df.columns) +def test_constraints_df_default(snapshot): + assert _df_snap(_build_instance().constraints_df()) == snapshot -def test_constraints_df_include_empty_drops_metadata(): - instance = _build_instance() - df = instance.constraints_df(include=[]) - assert METADATA_COLS.isdisjoint(df.columns) - # core columns are still present - assert "equality" in df.columns +def test_constraints_df_include_empty(snapshot): + """include=[] drops metadata columns; equality / function_type / used_ids stay.""" + assert _df_snap(_build_instance().constraints_df(include=[])) == snapshot # --------------------------------------------------------------------------- @@ -101,22 +102,16 @@ def test_constraints_df_include_empty_drops_metadata(): # --------------------------------------------------------------------------- -def test_solution_decision_variables_df_include_empty(): +def test_solution_decision_variables_df_include_empty(snapshot): instance = _build_instance() sol = instance.evaluate({0: 1, 1: 0, 2: 0}) - df = sol.decision_variables_df(include=[]) - assert METADATA_COLS.isdisjoint(df.columns) - assert "value" in df.columns + assert _df_snap(sol.decision_variables_df(include=[])) == snapshot -def test_sample_set_decision_variables_df_include_empty(): +def test_sample_set_decision_variables_df_include_empty(snapshot): instance = _build_instance() ss = instance.evaluate_samples({0: {0: 1, 1: 0, 2: 0}, 1: {0: 0, 1: 1, 2: 0}}) - df = ss.decision_variables_df(include=[]) - assert METADATA_COLS.isdisjoint(df.columns) - # per-sample value columns remain - assert 0 in df.columns - assert 1 in df.columns + assert _df_snap(ss.decision_variables_df(include=[])) == snapshot # --------------------------------------------------------------------------- diff --git a/python/ommx-tests/tests/test_dataframe_sidecars.py b/python/ommx-tests/tests/test_dataframe_sidecars.py index 8b71c98f0..ad9fe6aee 100644 --- a/python/ommx-tests/tests/test_dataframe_sidecars.py +++ b/python/ommx-tests/tests/test_dataframe_sidecars.py @@ -5,13 +5,16 @@ metadata in shapes that the wide `*_df` cannot represent without column-space explosion (provenance chains, per-id parameter maps with arbitrary keys). + +Most assertions are snapshot-based (syrupy) — the `.ambr` file is the +authoritative description of each accessor's column / index schema. +Update via `pytest --snapshot-update` after a deliberate API change. """ from __future__ import annotations -import math -import pytest import pandas as pd +import pytest from ommx.v1 import ( Constraint, DecisionVariable, @@ -52,31 +55,24 @@ def _instance_with_metadata() -> Instance: ) +def _df_snap(df: pd.DataFrame) -> str: + """Deterministic, snapshot-friendly rendering of a DataFrame.""" + return df.to_string(na_rep="") + + # --------------------------------------------------------------------------- # variable_metadata_df / variable_parameters_df # --------------------------------------------------------------------------- -def test_variable_metadata_df_is_id_indexed_with_columns(): - df = _instance_with_metadata().variable_metadata_df() - assert df.index.name == "variable_id" - assert list(df.index) == [0, 1, 2] - assert {"name", "subscripts", "description"} <= set(df.columns) - assert df.loc[0, "name"] == "x0" - assert df.loc[0, "description"] == "primary slot" - # name is set on every variable in the fixture but description only on 0. - assert pd.isna(df.loc[1, "description"]) +def test_variable_metadata_df(snapshot): + """id-indexed wide; columns name / subscripts / description; index = variable_id.""" + assert _df_snap(_instance_with_metadata().variable_metadata_df()) == snapshot -def test_variable_parameters_df_long_format_only_emits_present_keys(): - df = _instance_with_metadata().variable_parameters_df() - assert list(df.columns) == ["variable_id", "key", "value"] - # Variable 0 has 2 parameters, 1 and 2 have none. - rows = { - (int(vid), key): val - for vid, key, val in zip(df["variable_id"], df["key"], df["value"]) - } - assert rows == {(0, "role"): "primary", (0, "shard"): "a"} +def test_variable_parameters_df(snapshot): + """Long format. Variable 0 has 2 parameters, 1 and 2 have none → 2 rows.""" + assert _df_snap(_instance_with_metadata().variable_parameters_df()) == snapshot # --------------------------------------------------------------------------- @@ -84,23 +80,14 @@ def test_variable_parameters_df_long_format_only_emits_present_keys(): # --------------------------------------------------------------------------- -def test_constraint_metadata_df_default_kind_is_regular(): - df = _instance_with_metadata().constraint_metadata_df() - assert df.index.name == "regular_constraint_id" - assert list(df.index) == [10] - assert df.loc[10, "name"] == "balance" - assert df.loc[10, "subscripts"] == [0, 1] - assert df.loc[10, "description"] == "demand-balance row" +def test_constraint_metadata_df_default_kind_is_regular(snapshot): + """No kind= argument → kind="regular"; index = regular_constraint_id.""" + assert _df_snap(_instance_with_metadata().constraint_metadata_df()) == snapshot -def test_constraint_parameters_df_long_format(): - df = _instance_with_metadata().constraint_parameters_df() - assert list(df.columns) == ["regular_constraint_id", "key", "value"] - rows = { - (int(cid), key): val - for cid, key, val in zip(df["regular_constraint_id"], df["key"], df["value"]) - } - assert rows == {(10, "region"): "us-east", (10, "tier"): "gold"} +def test_constraint_parameters_df(snapshot): + """Long format with regular_constraint_id, key, value columns.""" + assert _df_snap(_instance_with_metadata().constraint_parameters_df()) == snapshot def test_unknown_kind_raises_value_error(): @@ -109,12 +96,28 @@ def test_unknown_kind_raises_value_error(): instance.constraint_metadata_df(kind="bogus") -def test_each_kind_uses_qualified_index_name(): - """Each constraint family's id column carries a kind-qualified name so - cross-kind joins are visible. Verifies the column / index naming on - indicator / one_hot / sos1 dispatch paths.""" +def test_indicator_kind_metadata_df(snapshot): + """Each constraint family's id column carries a kind-qualified index name.""" + assert ( + _df_snap(_special_instance().constraint_metadata_df(kind="indicator")) + == snapshot + ) + + +def test_one_hot_kind_metadata_df(snapshot): + assert ( + _df_snap(_special_instance().constraint_metadata_df(kind="one_hot")) == snapshot + ) + + +def test_sos1_kind_metadata_df(snapshot): + assert _df_snap(_special_instance().constraint_metadata_df(kind="sos1")) == snapshot + + +def _special_instance() -> Instance: + """Instance with one of each special constraint kind.""" x = [DecisionVariable.binary(i) for i in range(4)] - instance = Instance.from_components( + return Instance.from_components( decision_variables=x, objective=sum(x), constraints={}, @@ -129,17 +132,6 @@ def test_each_kind_uses_qualified_index_name(): sos1_constraints={7: Sos1Constraint(variables=[0, 1, 2, 3])}, sense=Instance.MAXIMIZE, ) - assert ( - instance.constraint_metadata_df(kind="indicator").index.name - == "indicator_constraint_id" - ) - assert ( - instance.constraint_metadata_df(kind="one_hot").index.name - == "one_hot_constraint_id" - ) - assert ( - instance.constraint_metadata_df(kind="sos1").index.name == "sos1_constraint_id" - ) # --------------------------------------------------------------------------- @@ -147,17 +139,12 @@ def test_each_kind_uses_qualified_index_name(): # --------------------------------------------------------------------------- -def test_provenance_empty_when_no_chain(): - df = _instance_with_metadata().constraint_provenance_df() - assert df.empty or list(df.columns) == [ - "regular_constraint_id", - "step", - "source_kind", - "source_id", - ] +def test_provenance_empty_when_no_chain(snapshot): + """Directly-authored constraints have no provenance chain.""" + assert _df_snap(_instance_with_metadata().constraint_provenance_df()) == snapshot -def test_provenance_after_one_hot_conversion(): +def test_provenance_after_one_hot_conversion(snapshot): """`convert_one_hot_to_constraint` promotes a OneHot row into a regular constraint; the new constraint records `OneHotConstraint(7)` in its provenance chain.""" @@ -169,18 +156,8 @@ def test_provenance_after_one_hot_conversion(): one_hot_constraints={7: OneHotConstraint(variables=[0, 1, 2])}, sense=Instance.MINIMIZE, ) - new_id = instance.convert_one_hot_to_constraint(7) - df = instance.constraint_provenance_df() - rows = [ - (int(cid), int(step), src_kind, int(src_id)) - for cid, step, src_kind, src_id in zip( - df["regular_constraint_id"], - df["step"], - df["source_kind"], - df["source_id"], - ) - ] - assert (int(new_id), 0, "OneHotConstraint", 7) in rows + instance.convert_one_hot_to_constraint(7) + assert _df_snap(instance.constraint_provenance_df()) == snapshot # --------------------------------------------------------------------------- @@ -188,30 +165,16 @@ def test_provenance_after_one_hot_conversion(): # --------------------------------------------------------------------------- -def test_removed_reasons_df_after_relax(): +def test_removed_reasons_df_after_relax(snapshot): + """relax_constraint with no extra parameters → 1 row, key/value = NA.""" instance = _instance_with_metadata() instance.relax_constraint(10, "test_reason") - df = instance.constraint_removed_reasons_df() - assert list(df.columns) == [ - "regular_constraint_id", - "reason", - "key", - "value", - ] - # The relax_constraint call provided no extra parameters → 1 row with - # NA key/value. - assert len(df) == 1 - assert int(df["regular_constraint_id"].iloc[0]) == 10 - assert df["reason"].iloc[0] == "test_reason" - key0 = df["key"].iloc[0] - assert ( - key0 is None or (isinstance(key0, float) and math.isnan(key0)) or pd.isna(key0) - ) + assert _df_snap(instance.constraint_removed_reasons_df()) == snapshot -def test_removed_reasons_df_with_parameters_after_one_hot_conversion(): +def test_removed_reasons_df_with_parameters_after_one_hot_conversion(snapshot): """`convert_one_hot_to_constraint` records the conversion reason with a - `constraint_ids` parameter — verifies the long-format expansion of the + `constraint_id` parameter — verifies the long-format expansion of the parameter map.""" x = [DecisionVariable.binary(i) for i in range(3)] instance = Instance.from_components( @@ -222,32 +185,27 @@ def test_removed_reasons_df_with_parameters_after_one_hot_conversion(): sense=Instance.MINIMIZE, ) instance.convert_one_hot_to_constraint(7) - df = instance.constraint_removed_reasons_df(kind="one_hot") - assert len(df) == 1 - assert int(df["one_hot_constraint_id"].iloc[0]) == 7 - assert df["reason"].iloc[0] == "ommx.Instance.convert_one_hot_to_constraint" - # The reason carries a single `constraint_id` parameter naming the - # promoted regular constraint id. - assert df["key"].iloc[0] == "constraint_id" - assert isinstance(df["value"].iloc[0], str) + assert _df_snap(instance.constraint_removed_reasons_df(kind="one_hot")) == snapshot # --------------------------------------------------------------------------- -# Solution / SampleSet expose the same surface; sanity-check the parity. +# Solution / SampleSet expose the same surface; the metadata stores are +# stage-independent so the rendered DataFrame is byte-identical to +# Instance's. # --------------------------------------------------------------------------- def test_solution_constraint_metadata_df_matches_instance(): instance = _instance_with_metadata() sol = instance.evaluate({0: 1, 1: 0, 2: 0}) - df_inst = instance.constraint_metadata_df() - df_sol = sol.constraint_metadata_df() - pd.testing.assert_frame_equal(df_inst, df_sol) + pd.testing.assert_frame_equal( + instance.constraint_metadata_df(), sol.constraint_metadata_df() + ) def test_sample_set_variable_metadata_df_matches_instance(): instance = _instance_with_metadata() ss = instance.evaluate_samples({0: {0: 1, 1: 0, 2: 0}}) - df_inst = instance.variable_metadata_df() - df_ss = ss.variable_metadata_df() - pd.testing.assert_frame_equal(df_inst, df_ss) + pd.testing.assert_frame_equal( + instance.variable_metadata_df(), ss.variable_metadata_df() + ) diff --git a/python/ommx/src/pandas.rs b/python/ommx/src/pandas.rs index fd71c6d29..53658216b 100644 --- a/python/ommx/src/pandas.rs +++ b/python/ommx/src/pandas.rs @@ -197,7 +197,9 @@ where /// Long-format parameters DataFrame for constraints. /// /// One row per (id, key) pair where `store.parameters(id)` is non-empty. -/// Columns: `id_col`, `key`, `value`. Default RangeIndex (no `set_index`). +/// Rows are sorted by `(id, key)` so the rendered output is deterministic +/// regardless of upstream insertion order. Columns: `id_col`, `key`, +/// `value`. Default RangeIndex (no `set_index`). pub fn constraint_parameters_dataframe<'py, ID>( py: Python<'py>, store: &ConstraintMetadataStore, @@ -207,17 +209,24 @@ pub fn constraint_parameters_dataframe<'py, ID>( where ID: IDType + Into, { - let mut entries: Vec> = Vec::new(); + let mut rows: Vec<(u64, &str, &str)> = Vec::new(); for id in ids { - let params = store.parameters(id); - for (key, value) in params { + let id_u64: u64 = id.into(); + for (key, value) in store.parameters(id) { + rows.push((id_u64, key.as_str(), value.as_str())); + } + } + rows.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1))); + let entries: Vec> = rows + .into_iter() + .map(|(id, key, value)| -> PyResult<_> { let dict = PyDict::new(py); - dict.set_item(id_col, Into::::into(id))?; + dict.set_item(id_col, id)?; dict.set_item("key", key)?; dict.set_item("value", value)?; - entries.push(dict.into_any()); - } - } + Ok(dict.into_any()) + }) + .collect::>()?; long_format_dataframe(py, entries) } @@ -255,8 +264,9 @@ where /// /// One row per (id, parameter_key) pair when the removed reason has /// parameters; ids without parameters get one row with `key`/`value` set to -/// `pandas.NA`. Columns: `id_col`, `reason`, `key`, `value`. Default -/// RangeIndex. +/// `pandas.NA`. Rows are sorted by `(id, key)` (NA-keyed rows sort first +/// for ids with no parameters). Columns: `id_col`, `reason`, `key`, +/// `value`. Default RangeIndex. pub fn constraint_removed_reasons_dataframe<'py, 'a, ID>( py: Python<'py>, removed: impl Iterator, @@ -265,27 +275,73 @@ pub fn constraint_removed_reasons_dataframe<'py, 'a, ID>( where ID: IDType + Into, { - let na = get_na(py)?; - let mut entries: Vec> = Vec::new(); + enum Row<'a> { + WithParam { + id: u64, + reason: &'a str, + key: &'a str, + value: &'a str, + }, + Bare { + id: u64, + reason: &'a str, + }, + } + impl<'a> Row<'a> { + fn sort_key(&self) -> (u64, Option<&'a str>) { + match self { + Row::Bare { id, .. } => (*id, None), + Row::WithParam { id, key, .. } => (*id, Some(*key)), + } + } + } + let mut rows: Vec> = Vec::new(); for (id, reason) in removed { + let id_u64: u64 = id.into(); if reason.parameters.is_empty() { - let dict = PyDict::new(py); - dict.set_item(id_col, Into::::into(id))?; - dict.set_item("reason", &reason.reason)?; - dict.set_item("key", &na)?; - dict.set_item("value", &na)?; - entries.push(dict.into_any()); + rows.push(Row::Bare { + id: id_u64, + reason: &reason.reason, + }); } else { for (key, value) in &reason.parameters { - let dict = PyDict::new(py); - dict.set_item(id_col, Into::::into(id))?; - dict.set_item("reason", &reason.reason)?; - dict.set_item("key", key)?; - dict.set_item("value", value)?; - entries.push(dict.into_any()); + rows.push(Row::WithParam { + id: id_u64, + reason: &reason.reason, + key: key.as_str(), + value: value.as_str(), + }); } } } + rows.sort_by(|a, b| a.sort_key().cmp(&b.sort_key())); + let na = get_na(py)?; + let entries: Vec> = rows + .into_iter() + .map(|row| -> PyResult<_> { + let dict = PyDict::new(py); + match row { + Row::WithParam { + id, + reason, + key, + value, + } => { + dict.set_item(id_col, id)?; + dict.set_item("reason", reason)?; + dict.set_item("key", key)?; + dict.set_item("value", value)?; + } + Row::Bare { id, reason } => { + dict.set_item(id_col, id)?; + dict.set_item("reason", reason)?; + dict.set_item("key", &na)?; + dict.set_item("value", &na)?; + } + } + Ok(dict.into_any()) + }) + .collect::>()?; long_format_dataframe(py, entries) } @@ -316,23 +372,32 @@ pub fn variable_metadata_dataframe<'py>( } /// Long-format parameters DataFrame for decision variables. +/// +/// Rows sorted by `(id, key)` for deterministic rendering. pub fn variable_parameters_dataframe<'py>( py: Python<'py>, store: &VariableMetadataStore, ids: impl Iterator, id_col: &str, ) -> PyResult> { - let mut entries: Vec> = Vec::new(); + let mut rows: Vec<(u64, &str, &str)> = Vec::new(); for id in ids { - let params = store.parameters(id); - for (key, value) in params { + let id_u64: u64 = id.into(); + for (key, value) in store.parameters(id) { + rows.push((id_u64, key.as_str(), value.as_str())); + } + } + rows.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1))); + let entries: Vec> = rows + .into_iter() + .map(|(id, key, value)| -> PyResult<_> { let dict = PyDict::new(py); - dict.set_item(id_col, Into::::into(id))?; + dict.set_item(id_col, id)?; dict.set_item("key", key)?; dict.set_item("value", value)?; - entries.push(dict.into_any()); - } - } + Ok(dict.into_any()) + }) + .collect::>()?; long_format_dataframe(py, entries) } From abd8c261fed3c63447940c5e6fedc42cb9d5052a Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 16:40:05 +0900 Subject: [PATCH 06/11] fix(pandas): three correctness fixes from Codex review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Solution / SampleSet sidecar accessors no longer double-count removed constraints. EvaluatedCollection.inner() and SampledCollection.inner() already include removed ids alongside active ones; chaining `.removed_reasons().keys()` was producing duplicate rows in `constraint_metadata_df` / `constraint_parameters_df` / `constraint_provenance_df` for every relaxed constraint. Drop the redundant chain — 24 sites across solution.rs / sample_set.rs. 2. `WithMetadata` and `WithMetadata>` now emit `parameters.{key}` columns. Pre-existing gap that this PR surfaced via `include=["parameters"]`: the unevaluated `DecisionVariable` impl already called `set_parameter_columns`, but the evaluated / sampled variants silently dropped the parameter map. 3. `WithSampleIds` now emits `parameters.{key}` columns instead of a single bare `parameters` column with `"k=v"`-formatted strings. Brings the shape in line with `NamedFunction` / `EvaluatedNamedFunction`, and lets `apply_include_filter` honour `include=[]` for this accessor. Add four regression tests in test_dataframe_sidecars.py: - Solution / SampleSet sidecar dedup with a relaxed constraint. - Solution / SampleSet decision_variables_df(include=["parameters"]) actually emits the parameter columns. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/test_dataframe_sidecars.py | 72 +++++++++++++++++++ python/ommx/src/pandas.rs | 9 +-- python/ommx/src/sample_set.rs | 60 ++++------------ python/ommx/src/solution.rs | 60 ++++------------ 4 files changed, 99 insertions(+), 102 deletions(-) diff --git a/python/ommx-tests/tests/test_dataframe_sidecars.py b/python/ommx-tests/tests/test_dataframe_sidecars.py index ad9fe6aee..729908108 100644 --- a/python/ommx-tests/tests/test_dataframe_sidecars.py +++ b/python/ommx-tests/tests/test_dataframe_sidecars.py @@ -209,3 +209,75 @@ def test_sample_set_variable_metadata_df_matches_instance(): pd.testing.assert_frame_equal( instance.variable_metadata_df(), ss.variable_metadata_df() ) + + +# --------------------------------------------------------------------------- +# Regression: Solution/SampleSet sidecars must NOT duplicate rows for +# removed constraints. EvaluatedCollection.inner() / SampledCollection.inner() +# already include removed ids, so chaining `.removed_reasons().keys()` would +# double-count them. +# --------------------------------------------------------------------------- + + +def _instance_with_relaxed_constraint() -> Instance: + """Two regular constraints, one of which is relaxed (moved to removed).""" + x = [DecisionVariable.binary(i) for i in range(3)] + instance = Instance.from_components( + decision_variables=x, + objective=sum(x), + constraints={ + 10: (x[0] + x[1] == 1).set_name("balance").set_parameters({"k": "v"}), + 11: (x[1] + x[2] <= 1).set_name("cap"), + }, + sense=Instance.MAXIMIZE, + ) + instance.relax_constraint(10, "test_reason") + return instance + + +def test_solution_sidecar_no_duplicate_rows_for_removed(): + """The relaxed id 10 lives in both inner() and removed_reasons() — the + sidecar must emit one row per id, not two.""" + instance = _instance_with_relaxed_constraint() + sol = instance.evaluate({0: 1, 1: 0, 2: 0}) + + meta = sol.constraint_metadata_df() + assert sorted(meta.index.tolist()) == [10, 11] + + params = sol.constraint_parameters_df() + rows = list(zip(params["regular_constraint_id"], params["key"])) + assert rows == [(10, "k")] # exactly one row, not duplicated + + +def test_sample_set_sidecar_no_duplicate_rows_for_removed(): + instance = _instance_with_relaxed_constraint() + ss = instance.evaluate_samples({0: {0: 1, 1: 0, 2: 0}}) + + meta = ss.constraint_metadata_df() + assert sorted(meta.index.tolist()) == [10, 11] + + params = ss.constraint_parameters_df() + rows = list(zip(params["regular_constraint_id"], params["key"])) + assert rows == [(10, "k")] + + +# --------------------------------------------------------------------------- +# Regression: include=["parameters"] on Solution/SampleSet decision variables +# now emits parameters.{key} columns (used to silently drop them). +# --------------------------------------------------------------------------- + + +def test_solution_decision_variables_df_emits_parameter_columns(): + instance = _instance_with_metadata() + sol = instance.evaluate({0: 1, 1: 0, 2: 0}) + df = sol.decision_variables_df(include=["parameters"]) + assert "parameters.role" in df.columns + assert "parameters.shard" in df.columns + + +def test_sample_set_decision_variables_df_emits_parameter_columns(): + instance = _instance_with_metadata() + ss = instance.evaluate_samples({0: {0: 1, 1: 0, 2: 0}}) + df = ss.decision_variables_df(include=["parameters"]) + assert "parameters.role" in df.columns + assert "parameters.shard" in df.columns diff --git a/python/ommx/src/pandas.rs b/python/ommx/src/pandas.rs index 53658216b..1b12638d0 100644 --- a/python/ommx/src/pandas.rs +++ b/python/ommx/src/pandas.rs @@ -1117,6 +1117,7 @@ impl<'m> ToPandasEntry // EvaluatedDecisionVariable has no substituted_value field dict.set_item("substituted_value", &na)?; dict.set_item("value", *dv.value())?; + set_parameter_columns(&dict, &m.parameters)?; Ok(dict) } } @@ -1214,6 +1215,7 @@ impl<'a, 'm> ToPandasEntry &m.subscripts, m.description.as_deref(), )?; + set_parameter_columns(&dict, &m.parameters)?; for &sample_id in self.item.sample_ids { let value = dv.samples().get(sample_id).copied(); dict.set_item(sample_id.into_inner(), value)?; @@ -1264,12 +1266,7 @@ impl<'a> ToPandasEntry for WithSampleIds<'a, &'a ommx::SampledNamedFunction> { &nf.subscripts, nf.description.as_deref(), )?; - let params: Vec = nf - .parameters - .iter() - .map(|(k, v)| format!("{k}={v}")) - .collect(); - dict.set_item("parameters", params)?; + set_parameter_columns(&dict, &nf.parameters)?; for &sample_id in self.sample_ids { let value = nf.evaluated_values().get(sample_id).copied(); dict.set_item(sample_id.into_inner(), value)?; diff --git a/python/ommx/src/sample_set.rs b/python/ommx/src/sample_set.rs index fd8c1f800..3cea915ac 100644 --- a/python/ommx/src/sample_set.rs +++ b/python/ommx/src/sample_set.rs @@ -920,10 +920,7 @@ impl SampleSet { crate::pandas::constraint_metadata_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -932,10 +929,7 @@ impl SampleSet { crate::pandas::constraint_metadata_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -944,10 +938,7 @@ impl SampleSet { crate::pandas::constraint_metadata_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -956,10 +947,7 @@ impl SampleSet { crate::pandas::constraint_metadata_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -981,10 +969,7 @@ impl SampleSet { crate::pandas::constraint_parameters_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -993,10 +978,7 @@ impl SampleSet { crate::pandas::constraint_parameters_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -1005,10 +987,7 @@ impl SampleSet { crate::pandas::constraint_parameters_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -1017,10 +996,7 @@ impl SampleSet { crate::pandas::constraint_parameters_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -1042,10 +1018,7 @@ impl SampleSet { crate::pandas::constraint_provenance_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -1054,10 +1027,7 @@ impl SampleSet { crate::pandas::constraint_provenance_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -1066,10 +1036,7 @@ impl SampleSet { crate::pandas::constraint_provenance_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -1078,10 +1045,7 @@ impl SampleSet { crate::pandas::constraint_provenance_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } diff --git a/python/ommx/src/solution.rs b/python/ommx/src/solution.rs index fc6ebee87..2aba45d51 100644 --- a/python/ommx/src/solution.rs +++ b/python/ommx/src/solution.rs @@ -797,10 +797,7 @@ impl Solution { crate::pandas::constraint_metadata_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -809,10 +806,7 @@ impl Solution { crate::pandas::constraint_metadata_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -821,10 +815,7 @@ impl Solution { crate::pandas::constraint_metadata_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -833,10 +824,7 @@ impl Solution { crate::pandas::constraint_metadata_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -858,10 +846,7 @@ impl Solution { crate::pandas::constraint_parameters_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -870,10 +855,7 @@ impl Solution { crate::pandas::constraint_parameters_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -882,10 +864,7 @@ impl Solution { crate::pandas::constraint_parameters_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -894,10 +873,7 @@ impl Solution { crate::pandas::constraint_parameters_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -919,10 +895,7 @@ impl Solution { crate::pandas::constraint_provenance_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -931,10 +904,7 @@ impl Solution { crate::pandas::constraint_provenance_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -943,10 +913,7 @@ impl Solution { crate::pandas::constraint_provenance_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } @@ -955,10 +922,7 @@ impl Solution { crate::pandas::constraint_provenance_dataframe( py, coll.metadata(), - coll.inner() - .keys() - .chain(coll.removed_reasons().keys()) - .copied(), + coll.inner().keys().copied(), id_col, ) } From 1df47de866c008ab1cc283e57ee393bfa14a2a16 Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 16:55:16 +0900 Subject: [PATCH 07/11] refactor(pandas): self-review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small follow-ups surfaced by the self-review pass. 1. **`constraint_kind_collection!` macro** in instance.rs / parametric_instance.rs / solution.rs / sample_set.rs collapses the 16-arm `ConstraintKind` match (4 sidecars × 4 kinds) per host into 4 macro invocations. Net -354 lines across the four files; the body passed to the macro is the only thing that varies between sidecars, and `coll` is rebound to the right collection per kind. 2. **`set_parameter_columns` sorts keys** before emitting the `parameters.{key}` columns. Wide `*_df` parameter columns now match the lexicographic order used by the long-format `*_parameters_df` builders, and become process-stable even when the upstream `Constraint.set_parameters` is given a `std::HashMap` (whose iteration is randomized per process). The helper also turns generic over the hasher so it accepts both `FnvHashMap` (SoA stores) and `std::HashMap` (`v1::Parameter`). 3. **Empty long-format dfs keep the schema.** `long_format_dataframe` now takes an explicit `columns: &[&str]` and passes it to `pd.DataFrame(columns=...)` when `entries` is empty, so empty results are usable in `pd.concat` and column-checking code instead of returning a column-less DataFrame. 4. **`v1::Parameter` ToPandasEntry** uses `set_parameter_columns` instead of an inline `format!("parameters.{key}")` loop, picking up the sort and matching the other 5 sites. Snapshot updates: 3 frames pick up the new lex order on `parameters.role` / `parameters.shard` and the explicit empty schema on `constraint_provenance_df`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__snapshots__/test_dataframe_include.ambr | 16 +- .../test_dataframe_sidecars.ambr | 2 +- python/ommx/src/instance.rs | 205 +++++------------ python/ommx/src/pandas.rs | 50 +++-- python/ommx/src/parametric_instance.rs | 205 +++++------------ python/ommx/src/sample_set.rs | 206 +++++------------- python/ommx/src/solution.rs | 206 +++++------------- 7 files changed, 268 insertions(+), 622 deletions(-) diff --git a/python/ommx-tests/tests/__snapshots__/test_dataframe_include.ambr b/python/ommx-tests/tests/__snapshots__/test_dataframe_include.ambr index fb76be0d5..91527b84e 100644 --- a/python/ommx-tests/tests/__snapshots__/test_dataframe_include.ambr +++ b/python/ommx-tests/tests/__snapshots__/test_dataframe_include.ambr @@ -15,11 +15,11 @@ # --- # name: test_decision_variables_df_default ''' - kind lower upper name subscripts description substituted_value parameters.shard parameters.role + kind lower upper name subscripts description substituted_value parameters.role parameters.shard id - 0 Binary 0.0 1.0 x0 [0] variable 0 0 primary - 1 Binary 0.0 1.0 x1 [1] variable 1 1 primary - 2 Binary 0.0 1.0 x2 [2] variable 2 2 primary + 0 Binary 0.0 1.0 x0 [0] variable 0 primary 0 + 1 Binary 0.0 1.0 x1 [1] variable 1 primary 1 + 2 Binary 0.0 1.0 x2 [2] variable 2 primary 2 ''' # --- # name: test_decision_variables_df_include_empty @@ -42,11 +42,11 @@ # --- # name: test_decision_variables_df_include_parameters_only ''' - kind lower upper substituted_value parameters.shard parameters.role + kind lower upper substituted_value parameters.role parameters.shard id - 0 Binary 0.0 1.0 0 primary - 1 Binary 0.0 1.0 1 primary - 2 Binary 0.0 1.0 2 primary + 0 Binary 0.0 1.0 primary 0 + 1 Binary 0.0 1.0 primary 1 + 2 Binary 0.0 1.0 primary 2 ''' # --- # name: test_sample_set_decision_variables_df_include_empty diff --git a/python/ommx-tests/tests/__snapshots__/test_dataframe_sidecars.ambr b/python/ommx-tests/tests/__snapshots__/test_dataframe_sidecars.ambr index 81cdab7bf..56393eaa4 100644 --- a/python/ommx-tests/tests/__snapshots__/test_dataframe_sidecars.ambr +++ b/python/ommx-tests/tests/__snapshots__/test_dataframe_sidecars.ambr @@ -36,7 +36,7 @@ # name: test_provenance_empty_when_no_chain ''' Empty DataFrame - Columns: [] + Columns: [regular_constraint_id, step, source_kind, source_id] Index: [] ''' # --- diff --git a/python/ommx/src/instance.rs b/python/ommx/src/instance.rs index 1645ac3bb..06a951d0d 100644 --- a/python/ommx/src/instance.rs +++ b/python/ommx/src/instance.rs @@ -15,6 +15,32 @@ use pyo3::{ }; use std::collections::{BTreeMap, BTreeSet, HashMap}; +/// Bind `coll` to the per-kind constraint collection on `self.inner` and +/// evaluate `body`. Used by the four `constraint_*_df` sidecar accessors so +/// the four `ConstraintKind` arms collapse to a single call site. +macro_rules! constraint_kind_collection { + ($self:expr, $kind:expr, |$coll:ident| $body:block) => { + match $kind { + ConstraintKind::Regular => { + let $coll = $self.inner.constraint_collection(); + $body + } + ConstraintKind::Indicator => { + let $coll = $self.inner.indicator_constraint_collection(); + $body + } + ConstraintKind::OneHot => { + let $coll = $self.inner.one_hot_constraint_collection(); + $body + } + ConstraintKind::Sos1 => { + let $coll = $self.inner.sos1_constraint_collection(); + $body + } + } + }; +} + /// Optimization problem instance. /// /// Note that this class also contains annotations like {attr}`~ommx.v1.Instance.title` which are not contained in protobuf message but stored in OMMX artifact. @@ -1982,44 +2008,14 @@ impl Instance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.constraint_collection(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.indicator_constraint_collection(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.one_hot_constraint_collection(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.sos1_constraint_collection(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + }) } /// Constraint parameters DataFrame (long format). @@ -2034,44 +2030,14 @@ impl Instance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.constraint_collection(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.indicator_constraint_collection(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.one_hot_constraint_collection(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.sos1_constraint_collection(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + }) } /// Constraint provenance DataFrame (long format). @@ -2086,44 +2052,14 @@ impl Instance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.constraint_collection(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.indicator_constraint_collection(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.one_hot_constraint_collection(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.sos1_constraint_collection(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + }) } /// Removed-constraint reasons DataFrame (long format). @@ -2139,44 +2075,13 @@ impl Instance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => crate::pandas::constraint_removed_reasons_dataframe( + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_removed_reasons_dataframe( py, - self.inner - .constraint_collection() - .removed() - .iter() - .map(|(id, (_, r))| (*id, r)), + coll.removed().iter().map(|(id, (_, r))| (*id, r)), id_col, - ), - ConstraintKind::Indicator => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .indicator_constraint_collection() - .removed() - .iter() - .map(|(id, (_, r))| (*id, r)), - id_col, - ), - ConstraintKind::OneHot => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .one_hot_constraint_collection() - .removed() - .iter() - .map(|(id, (_, r))| (*id, r)), - id_col, - ), - ConstraintKind::Sos1 => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .sos1_constraint_collection() - .removed() - .iter() - .map(|(id, (_, r))| (*id, r)), - id_col, - ), - } + ) + }) } /// Decision-variable metadata DataFrame (id-indexed wide format). diff --git a/python/ommx/src/pandas.rs b/python/ommx/src/pandas.rs index 1b12638d0..467e4184c 100644 --- a/python/ommx/src/pandas.rs +++ b/python/ommx/src/pandas.rs @@ -1,7 +1,9 @@ //! Thin wrapper around `pandas.DataFrame` for type-safe PyO3 bindings, //! plus shared helpers for building DataFrames from domain objects. -use fnv::FnvHashMap; +use std::collections::HashMap; +use std::hash::BuildHasher; + use ommx::{ ConstraintMetadata, ConstraintMetadataStore, DecisionVariableMetadata, Evaluate, IDType, Provenance, RemovedReason, VariableID, VariableIDSet, VariableMetadataStore, @@ -227,7 +229,7 @@ where Ok(dict.into_any()) }) .collect::>()?; - long_format_dataframe(py, entries) + long_format_dataframe(py, entries, &[id_col, "key", "value"]) } /// Long-format provenance DataFrame for constraints. @@ -257,7 +259,7 @@ where entries.push(dict.into_any()); } } - long_format_dataframe(py, entries) + long_format_dataframe(py, entries, &[id_col, "step", "source_kind", "source_id"]) } /// Long-format removed-reasons DataFrame for constraints. @@ -342,7 +344,7 @@ where Ok(dict.into_any()) }) .collect::>()?; - long_format_dataframe(py, entries) + long_format_dataframe(py, entries, &[id_col, "reason", "key", "value"]) } /// Wide id-indexed metadata DataFrame for decision variables. @@ -398,7 +400,7 @@ pub fn variable_parameters_dataframe<'py>( Ok(dict.into_any()) }) .collect::>()?; - long_format_dataframe(py, entries) + long_format_dataframe(py, entries, &[id_col, "key", "value"]) } fn provenance_parts(p: &Provenance) -> (&'static str, u64) { @@ -412,13 +414,23 @@ fn provenance_parts(p: &Provenance) -> (&'static str, u64) { /// Build a long-format DataFrame from pre-built entry dicts. /// /// No `set_index` call, so the DataFrame keeps its default RangeIndex. -/// Used by the `*_parameters_df` / `*_provenance_df` / -/// `*_removed_reasons_df` builders. +/// `columns` is the explicit schema used when `entries` is empty — +/// without it pandas would return a column-less DataFrame, breaking +/// `pd.concat` and any code that consumes the documented schema. Used +/// by the `*_parameters_df` / `*_provenance_df` / `*_removed_reasons_df` +/// builders. fn long_format_dataframe<'py>( py: Python<'py>, entries: Vec>, + columns: &[&str], ) -> PyResult> { let pandas = py.import("pandas")?; + if entries.is_empty() { + let kwargs = PyDict::new(py); + kwargs.set_item("columns", columns.to_vec())?; + let df = pandas.call_method("DataFrame", (), Some(&kwargs))?; + return df.cast_into().map_err(Into::into); + } let df = pandas.call_method1("DataFrame", (entries,))?; df.cast_into().map_err(Into::into) } @@ -592,11 +604,25 @@ pub fn set_metadata<'py>( } /// Set `parameters.{key}` columns from a string-string map. -pub fn set_parameter_columns( +/// +/// Keys are emitted in lexicographic order so the column order is +/// deterministic across runs (the underlying hashmap iteration order is +/// stable per insertion sequence for `FnvHashMap` but not for +/// `std::HashMap`, and upstream `Constraint.set_parameters` accepts a +/// `std::HashMap` whose iteration is randomized per process). Matches +/// the `(id, key)` sort used by the long-format `*_parameters_df` +/// builders. +/// +/// Generic over the hasher so callers can pass either an SoA-store +/// `FnvHashMap` or a `std::HashMap` from a `v1::Parameter`. +pub fn set_parameter_columns( dict: &Bound, - parameters: &FnvHashMap, + parameters: &HashMap, ) -> PyResult<()> { - for (key, value) in parameters { + let mut keys: Vec<&str> = parameters.keys().map(String::as_str).collect(); + keys.sort_unstable(); + for key in keys { + let value = ¶meters[key]; dict.set_item(format!("parameters.{key}"), value)?; } Ok(()) @@ -1085,9 +1111,7 @@ impl ToPandasEntry for ommx::v1::Parameter { &self.subscripts, self.description.as_deref(), )?; - for (key, value) in &self.parameters { - dict.set_item(format!("parameters.{key}"), value)?; - } + set_parameter_columns(&dict, &self.parameters)?; Ok(dict) } } diff --git a/python/ommx/src/parametric_instance.rs b/python/ommx/src/parametric_instance.rs index 63c8b06cc..2c9a47991 100644 --- a/python/ommx/src/parametric_instance.rs +++ b/python/ommx/src/parametric_instance.rs @@ -10,6 +10,32 @@ use ommx::{ConstraintID, NamedFunctionID, VariableID}; use pyo3::{exceptions::PyKeyError, prelude::*, types::PyBytes, Bound, PyAny}; use std::collections::{BTreeMap, BTreeSet, HashMap}; +/// Bind `coll` to the per-kind constraint collection on `self.inner` and +/// evaluate `body`. Mirror of the macro in `instance.rs` — `ParametricInstance` +/// shares the same `*_constraint_collection()` accessor names as `Instance`. +macro_rules! constraint_kind_collection { + ($self:expr, $kind:expr, |$coll:ident| $body:block) => { + match $kind { + ConstraintKind::Regular => { + let $coll = $self.inner.constraint_collection(); + $body + } + ConstraintKind::Indicator => { + let $coll = $self.inner.indicator_constraint_collection(); + $body + } + ConstraintKind::OneHot => { + let $coll = $self.inner.one_hot_constraint_collection(); + $body + } + ConstraintKind::Sos1 => { + let $coll = $self.inner.sos1_constraint_collection(); + $body + } + } + }; +} + #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[derive(Clone)] @@ -421,44 +447,14 @@ impl ParametricInstance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.constraint_collection(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.indicator_constraint_collection(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.one_hot_constraint_collection(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.sos1_constraint_collection(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + }) } /// Constraint parameters DataFrame (long format). @@ -470,44 +466,14 @@ impl ParametricInstance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.constraint_collection(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.indicator_constraint_collection(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.one_hot_constraint_collection(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.sos1_constraint_collection(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + }) } /// Constraint provenance DataFrame (long format). @@ -519,44 +485,14 @@ impl ParametricInstance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.constraint_collection(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.indicator_constraint_collection(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.one_hot_constraint_collection(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.sos1_constraint_collection(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + }) } /// Removed-constraint reasons DataFrame (long format). @@ -568,44 +504,13 @@ impl ParametricInstance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => crate::pandas::constraint_removed_reasons_dataframe( + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_removed_reasons_dataframe( py, - self.inner - .constraint_collection() - .removed() - .iter() - .map(|(id, (_, r))| (*id, r)), + coll.removed().iter().map(|(id, (_, r))| (*id, r)), id_col, - ), - ConstraintKind::Indicator => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .indicator_constraint_collection() - .removed() - .iter() - .map(|(id, (_, r))| (*id, r)), - id_col, - ), - ConstraintKind::OneHot => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .one_hot_constraint_collection() - .removed() - .iter() - .map(|(id, (_, r))| (*id, r)), - id_col, - ), - ConstraintKind::Sos1 => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .sos1_constraint_collection() - .removed() - .iter() - .map(|(id, (_, r))| (*id, r)), - id_col, - ), - } + ) + }) } /// Decision-variable metadata DataFrame (id-indexed). diff --git a/python/ommx/src/sample_set.rs b/python/ommx/src/sample_set.rs index 3cea915ac..91de5416d 100644 --- a/python/ommx/src/sample_set.rs +++ b/python/ommx/src/sample_set.rs @@ -13,6 +13,33 @@ use pyo3::{ }; use std::collections::{BTreeMap, BTreeSet, HashMap}; +/// Bind `coll` to the per-kind sampled constraint collection on +/// `self.inner` and evaluate `body`. `SampledCollection.inner()` already +/// includes removed ids alongside active ones, so the body iterates +/// `coll.inner().keys()` (no `.chain(removed_reasons())`). +macro_rules! constraint_kind_collection { + ($self:expr, $kind:expr, |$coll:ident| $body:block) => { + match $kind { + ConstraintKind::Regular => { + let $coll = $self.inner.constraints(); + $body + } + ConstraintKind::Indicator => { + let $coll = $self.inner.indicator_constraints(); + $body + } + ConstraintKind::OneHot => { + let $coll = $self.inner.one_hot_constraints(); + $body + } + ConstraintKind::Sos1 => { + let $coll = $self.inner.sos1_constraints(); + $body + } + } + }; +} + /// The output of sampling-based optimization algorithms, e.g. simulated annealing (SA). /// /// - Similar to `Solution` rather than the raw `State` message. @@ -914,44 +941,14 @@ impl SampleSet { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.constraints(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.indicator_constraints(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.one_hot_constraints(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.sos1_constraints(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + }) } /// Constraint parameters DataFrame (long format). @@ -963,44 +960,14 @@ impl SampleSet { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.constraints(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.indicator_constraints(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.one_hot_constraints(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.sos1_constraints(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + }) } /// Constraint provenance DataFrame (long format). @@ -1012,44 +979,14 @@ impl SampleSet { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.constraints(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.indicator_constraints(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.one_hot_constraints(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.sos1_constraints(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + }) } /// Removed-constraint reasons DataFrame (long format). @@ -1061,44 +998,13 @@ impl SampleSet { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => crate::pandas::constraint_removed_reasons_dataframe( + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_removed_reasons_dataframe( py, - self.inner - .constraints() - .removed_reasons() - .iter() - .map(|(id, r)| (*id, r)), + coll.removed_reasons().iter().map(|(id, r)| (*id, r)), id_col, - ), - ConstraintKind::Indicator => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .indicator_constraints() - .removed_reasons() - .iter() - .map(|(id, r)| (*id, r)), - id_col, - ), - ConstraintKind::OneHot => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .one_hot_constraints() - .removed_reasons() - .iter() - .map(|(id, r)| (*id, r)), - id_col, - ), - ConstraintKind::Sos1 => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .sos1_constraints() - .removed_reasons() - .iter() - .map(|(id, r)| (*id, r)), - id_col, - ), - } + ) + }) } /// Decision-variable metadata DataFrame (id-indexed). diff --git a/python/ommx/src/solution.rs b/python/ommx/src/solution.rs index 2aba45d51..7bfa0855f 100644 --- a/python/ommx/src/solution.rs +++ b/python/ommx/src/solution.rs @@ -11,6 +11,33 @@ use pyo3::{ }; use std::collections::{BTreeSet, HashMap}; +/// Bind `coll` to the per-kind evaluated constraint collection on +/// `self.inner` and evaluate `body`. `EvaluatedCollection.inner()` already +/// includes removed ids alongside active ones, so the body iterates +/// `coll.inner().keys()` (no `.chain(removed_reasons())`). +macro_rules! constraint_kind_collection { + ($self:expr, $kind:expr, |$coll:ident| $body:block) => { + match $kind { + ConstraintKind::Regular => { + let $coll = $self.inner.evaluated_constraints(); + $body + } + ConstraintKind::Indicator => { + let $coll = $self.inner.evaluated_indicator_constraints(); + $body + } + ConstraintKind::OneHot => { + let $coll = $self.inner.evaluated_one_hot_constraints(); + $body + } + ConstraintKind::Sos1 => { + let $coll = $self.inner.evaluated_sos1_constraints(); + $body + } + } + }; +} + /// Idiomatic wrapper of `ommx.v1.Solution` protobuf message. /// /// This also contains annotations not contained in protobuf message, and will be stored in OMMX artifact. @@ -791,44 +818,14 @@ impl Solution { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.evaluated_constraints(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.evaluated_indicator_constraints(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.evaluated_one_hot_constraints(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.evaluated_sos1_constraints(); - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + }) } /// Constraint parameters DataFrame (long format). @@ -840,44 +837,14 @@ impl Solution { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.evaluated_constraints(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.evaluated_indicator_constraints(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.evaluated_one_hot_constraints(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.evaluated_sos1_constraints(); - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + }) } /// Constraint provenance DataFrame (long format). @@ -889,44 +856,14 @@ impl Solution { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => { - let coll = self.inner.evaluated_constraints(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Indicator => { - let coll = self.inner.evaluated_indicator_constraints(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::OneHot => { - let coll = self.inner.evaluated_one_hot_constraints(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - ConstraintKind::Sos1 => { - let coll = self.inner.evaluated_sos1_constraints(); - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - } - } + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + }) } /// Removed-constraint reasons DataFrame (long format). @@ -938,44 +875,13 @@ impl Solution { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - match kind { - ConstraintKind::Regular => crate::pandas::constraint_removed_reasons_dataframe( + constraint_kind_collection!(self, kind, |coll| { + crate::pandas::constraint_removed_reasons_dataframe( py, - self.inner - .evaluated_constraints() - .removed_reasons() - .iter() - .map(|(id, r)| (*id, r)), + coll.removed_reasons().iter().map(|(id, r)| (*id, r)), id_col, - ), - ConstraintKind::Indicator => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .evaluated_indicator_constraints() - .removed_reasons() - .iter() - .map(|(id, r)| (*id, r)), - id_col, - ), - ConstraintKind::OneHot => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .evaluated_one_hot_constraints() - .removed_reasons() - .iter() - .map(|(id, r)| (*id, r)), - id_col, - ), - ConstraintKind::Sos1 => crate::pandas::constraint_removed_reasons_dataframe( - py, - self.inner - .evaluated_sos1_constraints() - .removed_reasons() - .iter() - .map(|(id, r)| (*id, r)), - id_col, - ), - } + ) + }) } /// Decision-variable metadata DataFrame (id-indexed). From a2abebb976274fda01f6453fbba061f65f627c47 Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 17:16:57 +0900 Subject: [PATCH 08/11] docs: update book for *_df method conversion + new accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tutorial / user_guide / release-note pages updated for the v3.0.0a3 DataFrame API: - All `*_df` accesses in MyST code-cells now call the method form (`solution.constraints_df()`), matching the property→method conversion that landed in PR #846. - `{attr}` cross-references for `*_df` accessors switch to `{meth}` in tables and prose: `capability_model.md`, `special_constraints.md`, `release_note/ommx-3.0.md` (en + ja). Non-`_df` properties (`removed_one_hot_constraints`, etc.) keep `{attr}`. - "property" → "method" wording fixes in `tutorial/solve_with_ommx_adapter.md` (en + ja) for the prose that describes what `decision_variables_df` / `constraints_df` return. - New "Unreleased" entry on `release_note/ommx-3.0.md` (en + ja) documenting the property→method conversion, the `include=` parameter on the wide `*_df` methods, and the six new long-format / id-indexed sidecar accessors with their `kind=` dispatch. `task python:book:test` builds cleanly with all code-cells executing. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/en/release_note/ommx-3.0.md | 38 +++++++++++++++++-- docs/en/tutorial/share_in_ommx_artifact.md | 2 +- docs/en/tutorial/solve_with_ommx_adapter.md | 10 ++--- docs/en/tutorial/switching_adapters.md | 2 +- .../tsp_sampling_with_openjij_adapter.md | 6 +-- docs/en/user_guide/capability_model.md | 6 +-- docs/en/user_guide/instance.md | 4 +- docs/en/user_guide/parametric_instance.md | 4 +- docs/en/user_guide/sample_set.md | 4 +- docs/en/user_guide/solution.md | 4 +- docs/en/user_guide/special_constraints.md | 12 +++--- docs/ja/release_note/ommx-3.0.md | 38 +++++++++++++++++-- docs/ja/tutorial/share_in_ommx_artifact.md | 2 +- docs/ja/tutorial/solve_with_ommx_adapter.md | 10 ++--- docs/ja/tutorial/switching_adapters.md | 2 +- .../tsp_sampling_with_openjij_adapter.md | 6 +-- docs/ja/user_guide/capability_model.md | 6 +-- docs/ja/user_guide/instance.md | 2 +- docs/ja/user_guide/parametric_instance.md | 4 +- docs/ja/user_guide/sample_set.md | 4 +- docs/ja/user_guide/solution.md | 4 +- docs/ja/user_guide/special_constraints.md | 12 +++--- 22 files changed, 123 insertions(+), 59 deletions(-) diff --git a/docs/en/release_note/ommx-3.0.md b/docs/en/release_note/ommx-3.0.md index 47fc7fe46..030532f9a 100644 --- a/docs/en/release_note/ommx-3.0.md +++ b/docs/en/release_note/ommx-3.0.md @@ -6,6 +6,38 @@ Python SDK 3.0.0 contains breaking API changes. A migration guide is available i ## Unreleased +### ⚠ `*_df` accessors are methods + `include=` filter + sidecar DataFrames ([#846](https://github.com/Jij-Inc/ommx/pull/846)) + +Every `*_df` accessor on `Instance` / `ParametricInstance` / `Solution` / `SampleSet` is now a regular method instead of a `#[getter]` property. Existing call sites need parentheses: + +```python +# Before +df = solution.constraints_df + +# After +df = solution.constraints_df() +``` + +The wide `*_df` methods take an `include` argument that gates the metadata / parameters column families. The default `include=("metadata", "parameters")` preserves the v2-equivalent wide shape: + +```python +solution.decision_variables_df() # core + metadata + parameters +solution.decision_variables_df(include=[]) # core only +solution.decision_variables_df(include=["metadata"]) # core + metadata +solution.decision_variables_df(include=["parameters"]) # core + parameters +``` + +Six new long-format / id-indexed sidecar accessors read directly from the SoA metadata stores. `kind=` selects the constraint family (`"regular"` / `"indicator"` / `"one_hot"` / `"sos1"`, default `"regular"`): + +- `constraint_metadata_df(kind=...)` — id-indexed (`name` / `subscripts` / `description`) +- `constraint_parameters_df(kind=...)` — long format (`{kind}_constraint_id` / `key` / `value`) +- `constraint_provenance_df(kind=...)` — long format (`{kind}_constraint_id` / `step` / `source_kind` / `source_id`) +- `constraint_removed_reasons_df(kind=...)` — long format (`{kind}_constraint_id` / `reason` / `key` / `value`) +- `variable_metadata_df()` — id-indexed +- `variable_parameters_df()` — long format + +Sidecar index names are kind-qualified (`regular_constraint_id` / `indicator_constraint_id` / `one_hot_constraint_id` / `sos1_constraint_id` / `variable_id`) so accidental cross-id-space `df.join()` mistakes surface in `df.head()` and friends. Long-format `*_parameters_df` / `*_removed_reasons_df` rows are sorted by `(id, key)`, and empty long-format DataFrames keep their column schema instead of returning a column-less frame. + ### ⚠ `to_bytes` / `from_bytes` removed from non-top-level types ([#845](https://github.com/Jij-Inc/ommx/pull/845)) Bytes serialization is removed from the following component-level types: @@ -107,14 +139,14 @@ Accordingly, the legacy `ConstraintHints` / `OneHot` / `Sos1` classes, the `Inst ### ⚠ `removed_reason` column split into a separate table ([#796](https://github.com/Jij-Inc/ommx/pull/796)) -In v2.5.1 {attr}`Solution.constraints_df ` carried a `removed_reason` column. In v3.0.0a2 that column is split out into a separate {attr}`Solution.removed_reasons_df ` table, which you can join on if you need the previous shape. The same change applies to {class}`~ommx.v1.SampleSet`. +In v2.5.1 {meth}`Solution.constraints_df ` carried a `removed_reason` column. In v3.0.0a2 that column is split out into a separate {meth}`Solution.removed_reasons_df ` table, which you can join on if you need the previous shape. The same change applies to {class}`~ommx.v1.SampleSet`. ```python # Before (2.5.1) df = solution.constraints_df # contains a 'removed_reason' column -# After (3.0.0a2) -df = solution.constraints_df.join(solution.removed_reasons_df) +# After (3.0.0a3 — `*_df` are now methods) +df = solution.constraints_df().join(solution.removed_reasons_df()) ``` Corresponding `*_removed_reasons_df` accessors are also provided for Indicator, OneHot, and SOS1. diff --git a/docs/en/tutorial/share_in_ommx_artifact.md b/docs/en/tutorial/share_in_ommx_artifact.md index 1c022f4e3..f7e423537 100644 --- a/docs/en/tutorial/share_in_ommx_artifact.md +++ b/docs/en/tutorial/share_in_ommx_artifact.md @@ -86,7 +86,7 @@ instance = Instance.from_components( solution = OMMXPySCIPOptAdapter.solve(instance) # Analyze the optimal solution -df_vars = solution.decision_variables_df +df_vars = solution.decision_variables_df() df = pd.DataFrame.from_dict( { "Item Number": df_vars.index, diff --git a/docs/en/tutorial/solve_with_ommx_adapter.md b/docs/en/tutorial/solve_with_ommx_adapter.md index 7d218bbc5..3f6724d3f 100644 --- a/docs/en/tutorial/solve_with_ommx_adapter.md +++ b/docs/en/tutorial/solve_with_ommx_adapter.md @@ -143,10 +143,10 @@ To do this, we use the properties implemented in the `ommx.v1.Solution` class. ### Analyzing the Optimal Solution -The `decision_variables_df` property returns a `pandas.DataFrame` object containing information on each variable, such as ID, type, name, and value: +The `decision_variables_df()` method returns a `pandas.DataFrame` object containing information on each variable, such as ID, type, name, and value: ```{code-cell} ipython3 -solution.decision_variables_df +solution.decision_variables_df() ``` Using this `pandas.DataFrame` object, you can easily create a table in pandas that shows, for example, "whether to put items in the knapsack": @@ -154,7 +154,7 @@ Using this `pandas.DataFrame` object, you can easily create a table in pandas th ```{code-cell} ipython3 import pandas as pd -df = solution.decision_variables_df +df = solution.decision_variables_df() pd.DataFrame.from_dict( { "Item number": df.index, @@ -178,10 +178,10 @@ assert np.isclose(solution.objective, expected) ### Analyzing Constraints -The `constraints_df` property returns a `pandas.DataFrame` object that includes details about each constraint's equality or inequality, its left-hand-side value (`"value"`), name, and more: +The `constraints_df()` method returns a `pandas.DataFrame` object that includes details about each constraint's equality or inequality, its left-hand-side value (`"value"`), name, and more: ```{code-cell} ipython3 -solution.constraints_df +solution.constraints_df() ``` Specifically, the `"value"` is helpful for understanding how much slack remains in each constraint. In this case, item 0 has weight $w_0 = 11$, item 3 has weight $w_3 = 35$, and the knapsack's capacity $W$ is $47$. Therefore, for the weight constraint diff --git a/docs/en/tutorial/switching_adapters.md b/docs/en/tutorial/switching_adapters.md index 3b8109f00..fd1727911 100644 --- a/docs/en/tutorial/switching_adapters.md +++ b/docs/en/tutorial/switching_adapters.md @@ -99,7 +99,7 @@ It would be convenient to concatenate the `pandas.DataFrame` obtained with `deci import pandas decision_variables = pandas.concat([ - solution.decision_variables_df.assign(solver=solver) + solution.decision_variables_df().assign(solver=solver) for solver, solution in solutions.items() ]) decision_variables diff --git a/docs/en/tutorial/tsp_sampling_with_openjij_adapter.md b/docs/en/tutorial/tsp_sampling_with_openjij_adapter.md index f040a5981..ae03b3920 100644 --- a/docs/en/tutorial/tsp_sampling_with_openjij_adapter.md +++ b/docs/en/tutorial/tsp_sampling_with_openjij_adapter.md @@ -157,14 +157,14 @@ To view the feasibility for each constraint, use the `summary_with_constraints` sample_set.summary_with_constraints ``` -For more detailed information, you can use the `SampleSet.decision_variables` and `SampleSet.constraints` properties. +For more detailed information, you can use the `SampleSet.decision_variables_df()` and `SampleSet.constraints_df()` methods. ```{code-cell} ipython3 -sample_set.decision_variables_df.head(2) +sample_set.decision_variables_df().head(2) ``` ```{code-cell} ipython3 -sample_set.constraints_df.head(2) +sample_set.constraints_df().head(2) ``` To obtain the samples, use the `SampleSet.extract_decision_variables` method. This interprets the samples using the `name` and `subscripts` registered when creating `ommx.v1.DecisionVariables`. For example, to get the value of the decision variable named `x` with `sample_id=1`, use the following to obtain it in the form of `dict[subscripts, value]`. diff --git a/docs/en/user_guide/capability_model.md b/docs/en/user_guide/capability_model.md index 968b11a79..691fc289b 100644 --- a/docs/en/user_guide/capability_model.md +++ b/docs/en/user_guide/capability_model.md @@ -156,9 +156,9 @@ The original special constraints are not discarded; they are kept as "removed" e | Original type | Removed dict | DataFrame | |---|---|---| -| OneHotConstraint | {attr}`~ommx.v1.Instance.removed_one_hot_constraints` | {attr}`~ommx.v1.Instance.removed_one_hot_constraints_df` | -| Sos1Constraint | {attr}`~ommx.v1.Instance.removed_sos1_constraints` | {attr}`~ommx.v1.Instance.removed_sos1_constraints_df` | -| IndicatorConstraint | {attr}`~ommx.v1.Instance.removed_indicator_constraints` | {attr}`~ommx.v1.Instance.removed_indicator_constraints_df` | +| OneHotConstraint | {attr}`~ommx.v1.Instance.removed_one_hot_constraints` | {meth}`~ommx.v1.Instance.removed_one_hot_constraints_df` | +| Sos1Constraint | {attr}`~ommx.v1.Instance.removed_sos1_constraints` | {meth}`~ommx.v1.Instance.removed_sos1_constraints_df` | +| IndicatorConstraint | {attr}`~ommx.v1.Instance.removed_indicator_constraints` | {meth}`~ommx.v1.Instance.removed_indicator_constraints_df` | Each entry ({class}`~ommx.v1.RemovedOneHotConstraint` / {class}`~ommx.v1.RemovedSos1Constraint` / {class}`~ommx.v1.RemovedIndicatorConstraint`) records a `removed_reason` string (for example, `"ommx.Instance.convert_one_hot_to_constraint"`) and stores the generated regular-constraint IDs in `removed_reason_parameters`. The key name and shape differ by constraint type: diff --git a/docs/en/user_guide/instance.md b/docs/en/user_guide/instance.md index 082d77e43..899678892 100644 --- a/docs/en/user_guide/instance.md +++ b/docs/en/user_guide/instance.md @@ -63,7 +63,7 @@ instance.sense == Instance.MAXIMIZE Decision variables and constraints can be obtained in the form of [`pandas.DataFrame`](https://pandas.pydata.org/pandas-docs/stable/reference/frame.html). ```{code-cell} ipython3 -instance.decision_variables_df +instance.decision_variables_df() ``` First, `kind`, `lower`, and `upper` are essential information for the mathematical model. @@ -95,7 +95,7 @@ print(f"{x1.id=}, {x1.name=}") Next, let's look at the constraints. ```{code-cell} ipython3 -instance.constraints_df +instance.constraints_df() ``` In OMMX, constraints are also managed by ID, and this ID is independent of the decision variable ID. The ID is assigned when a constraint is attached to an `Instance`: the key you use in the `constraints` dictionary passed to [`Instance.from_components`](https://jij-inc.github.io/ommx/python/ommx/autoapi/ommx/v1/index.html#ommx.v1.Instance.from_components) becomes the constraint ID. diff --git a/docs/en/user_guide/parametric_instance.md b/docs/en/user_guide/parametric_instance.md index 36996e4e2..18f830321 100644 --- a/docs/en/user_guide/parametric_instance.md +++ b/docs/en/user_guide/parametric_instance.md @@ -57,10 +57,10 @@ parametric_instance = ParametricInstance.from_components( ) ``` -Like `ommx.v1.Instance`, you can view the decision variables and constraints as DataFrames through the `decision_variables_df` and `constraints_df` properties. In addition, `ommx.v1.ParametricInstance` has a `parameters_df` property for viewing parameter information in a DataFrame. +Like `ommx.v1.Instance`, you can view the decision variables and constraints as DataFrames through the `decision_variables_df()` and `constraints_df()` methods. In addition, `ommx.v1.ParametricInstance` has a `parameters_df()` method for viewing parameter information in a DataFrame. ```{code-cell} ipython3 -parametric_instance.parameters_df +parametric_instance.parameters_df() ``` Next, let’s assign specific values to the parameters. Use `ParametricInstance.with_parameters`, which takes a dictionary mapping each `ommx.v1.Parameter` ID to its corresponding value. diff --git a/docs/en/user_guide/sample_set.md b/docs/en/user_guide/sample_set.md index b58aaf802..73952f6dd 100644 --- a/docs/en/user_guide/sample_set.md +++ b/docs/en/user_guide/sample_set.md @@ -97,7 +97,7 @@ solution = sample_set.get(sample_id=0) assert isinstance(solution, Solution) print(f"{solution.objective=}") -solution.decision_variables_df +solution.decision_variables_df() ``` Retrieving the best solution @@ -108,7 +108,7 @@ Retrieving the best solution solution = sample_set.best_feasible print(f"{solution.objective=}") -solution.decision_variables_df +solution.decision_variables_df() ``` Of course, if the problem is a minimization, the sample with the smallest objective value will be returned. If no feasible samples exist, an error will be raised. diff --git a/docs/en/user_guide/solution.md b/docs/en/user_guide/solution.md index 7d36a0276..cc861ac73 100644 --- a/docs/en/user_guide/solution.md +++ b/docs/en/user_guide/solution.md @@ -55,13 +55,13 @@ solution = instance.evaluate({1: 1, 2: 0}) # x=1, y=0 The generated `ommx.v1.Solution` inherits most of the information from the `ommx.v1.Instance`. Let's first look at the decision variables. ```{code-cell} ipython3 -solution.decision_variables_df +solution.decision_variables_df() ``` In addition to the required attributes—ID, `kind`, `lower`, and `upper`—it also inherits metadata such as `name`. Additionally, the `value` assigned in `evaluate` is stored. Similarly, the evaluation value is added to the constraints as `value`. ```{code-cell} ipython3 -solution.constraints_df +solution.constraints_df() ``` The `objective` property contains the value of the objective function, and the `feasible` property contains whether the constraints are satisfied. diff --git a/docs/en/user_guide/special_constraints.md b/docs/en/user_guide/special_constraints.md index 91736831c..b9822fd2d 100644 --- a/docs/en/user_guide/special_constraints.md +++ b/docs/en/user_guide/special_constraints.md @@ -180,19 +180,19 @@ The {class}`~ommx.v1.Solution` or {class}`~ommx.v1.SampleSet` obtained after sol | Constraint type | Accessor (on `Solution`) | |---|---| -| Regular | {attr}`~ommx.v1.Solution.constraints_df` | -| Indicator | {attr}`~ommx.v1.Solution.indicator_constraints_df` | -| OneHot | {attr}`~ommx.v1.Solution.one_hot_constraints_df` | -| SOS1 | {attr}`~ommx.v1.Solution.sos1_constraints_df` | +| Regular | {meth}`~ommx.v1.Solution.constraints_df` | +| Indicator | {meth}`~ommx.v1.Solution.indicator_constraints_df` | +| OneHot | {meth}`~ommx.v1.Solution.one_hot_constraints_df` | +| SOS1 | {meth}`~ommx.v1.Solution.sos1_constraints_df` | The Indicator DataFrame includes an `indicator_active` column that disambiguates "the indicator was OFF (constraint trivially satisfied)" from "the indicator was ON and the constraint was actually satisfied". Indicator constraints do not carry a dual variable — a dual value is not well-defined for a conditional constraint — so `dual_variable` is omitted. ### removed_reasons_df separation -For regular constraints, `removed_reason` is no longer a column of {attr}`~ommx.v1.Solution.constraints_df`. It lives in {attr}`~ommx.v1.Solution.removed_reasons_df` as a separate table, which you can join as needed: +For regular constraints, `removed_reason` is no longer a column of {meth}`~ommx.v1.Solution.constraints_df`. It lives in {meth}`~ommx.v1.Solution.removed_reasons_df` as a separate table, which you can join as needed: ```python -df = solution.constraints_df.join(solution.removed_reasons_df) +df = solution.constraints_df().join(solution.removed_reasons_df()) ``` The same split applies to Indicator, OneHot, and SOS1: each has its own `indicator_removed_reasons_df` / `one_hot_removed_reasons_df` / `sos1_removed_reasons_df` on both {class}`~ommx.v1.Solution` and {class}`~ommx.v1.SampleSet`. diff --git a/docs/ja/release_note/ommx-3.0.md b/docs/ja/release_note/ommx-3.0.md index 097720bd0..feb9ec2d7 100644 --- a/docs/ja/release_note/ommx-3.0.md +++ b/docs/ja/release_note/ommx-3.0.md @@ -6,6 +6,38 @@ Python SDK 3.0.0にはAPIの破壊的な変更が含まれます。マイグレ ## Unreleased +### ⚠ `*_df` アクセサがメソッドに変更 + `include=` 追加 + Sidecar DataFrame ([#846](https://github.com/Jij-Inc/ommx/pull/846)) + +`Instance` / `ParametricInstance` / `Solution` / `SampleSet` のすべての `*_df` アクセサを `#[getter]` プロパティから通常のメソッドに変更しました。プロパティアクセスからメソッド呼び出しに移行する必要があります: + +```python +# Before +df = solution.constraints_df + +# After +df = solution.constraints_df() +``` + +ワイドな `*_df` メソッドには `include` 引数が追加され、メタデータ系・パラメータ系のカラムをそれぞれ ON/OFF できます。デフォルトの `include=("metadata", "parameters")` は v2 互換のワイド形を維持します: + +```python +solution.decision_variables_df() # core + metadata + parameters +solution.decision_variables_df(include=[]) # core only +solution.decision_variables_df(include=["metadata"]) # core + metadata +solution.decision_variables_df(include=["parameters"]) # core + parameters +``` + +加えて、SoA メタデータストアを直接読む 6 種類の long-format / id-indexed sidecar アクセサが追加されました。`kind=` で対象の制約ファミリーを切り替えます (`"regular"` / `"indicator"` / `"one_hot"` / `"sos1"`、デフォルト `"regular"`): + +- `constraint_metadata_df(kind=...)` — id-indexed (`name` / `subscripts` / `description`) +- `constraint_parameters_df(kind=...)` — long format (`{kind}_constraint_id` / `key` / `value`) +- `constraint_provenance_df(kind=...)` — long format (`{kind}_constraint_id` / `step` / `source_kind` / `source_id`) +- `constraint_removed_reasons_df(kind=...)` — long format (`{kind}_constraint_id` / `reason` / `key` / `value`) +- `variable_metadata_df()` — id-indexed +- `variable_parameters_df()` — long format + +Sidecar の index 名はファミリーごとに qualified (`regular_constraint_id` / `indicator_constraint_id` / `one_hot_constraint_id` / `sos1_constraint_id` / `variable_id`) になっており、別 ID 空間どうしを誤って `df.join()` した場合に `df.head()` 等で気づきやすくなっています。`*_parameters_df` / `*_removed_reasons_df` の行は `(id, key)` 順にソート済み、空の long-format DataFrame もスキーマ列だけ持つ形で返ります。 + ### ⚠ 部品型から `to_bytes` / `from_bytes` を削除 ([#845](https://github.com/Jij-Inc/ommx/pull/845)) 以下の部品型からバイト列シリアライズを削除しました: @@ -107,14 +139,14 @@ Instance.from_components(..., constraints={5: c}, ...) ### ⚠ `removed_reason` カラムを別テーブルに分離 ([#796](https://github.com/Jij-Inc/ommx/pull/796)) -v2.5.1 までは {attr}`Solution.constraints_df ` に `removed_reason` カラムが含まれていましたが、v3.0.0a2 ではこれを {attr}`Solution.removed_reasons_df ` という別テーブルに分離しました。従来の形が必要な場合は join してください。同じ変更が {class}`~ommx.v1.SampleSet` にも適用されています。 +v2.5.1 までは {meth}`Solution.constraints_df ` に `removed_reason` カラムが含まれていましたが、v3.0.0a2 ではこれを {meth}`Solution.removed_reasons_df ` という別テーブルに分離しました。従来の形が必要な場合は join してください。同じ変更が {class}`~ommx.v1.SampleSet` にも適用されています。 ```python # Before (2.5.1) df = solution.constraints_df # 'removed_reason' カラムを含む -# After (3.0.0a2) -df = solution.constraints_df.join(solution.removed_reasons_df) +# After (3.0.0a3 — `*_df` はメソッドになりました) +df = solution.constraints_df().join(solution.removed_reasons_df()) ``` Indicator / OneHot / SOS1 それぞれに対応する `*_removed_reasons_df` も提供されています。 diff --git a/docs/ja/tutorial/share_in_ommx_artifact.md b/docs/ja/tutorial/share_in_ommx_artifact.md index c636fdd2a..f7aa74550 100644 --- a/docs/ja/tutorial/share_in_ommx_artifact.md +++ b/docs/ja/tutorial/share_in_ommx_artifact.md @@ -86,7 +86,7 @@ instance = Instance.from_components( solution = OMMXPySCIPOptAdapter.solve(instance) # 最適解の分析をする -df_vars = solution.decision_variables_df +df_vars = solution.decision_variables_df() df = pd.DataFrame.from_dict( { "アイテムの番号": df_vars.index, diff --git a/docs/ja/tutorial/solve_with_ommx_adapter.md b/docs/ja/tutorial/solve_with_ommx_adapter.md index 3d4866785..88fccd820 100644 --- a/docs/ja/tutorial/solve_with_ommx_adapter.md +++ b/docs/ja/tutorial/solve_with_ommx_adapter.md @@ -144,10 +144,10 @@ solution = OMMXPySCIPOptAdapter.solve(instance) ### 最適解の分析 -`decision_variables` プロパティは、決定変数のID、種類、名前、値などの情報を含む `pandas.DataFrame` オブジェクトを返します: +`decision_variables_df()` メソッドは、決定変数のID、種類、名前、値などの情報を含む `pandas.DataFrame` オブジェクトを返します: ```{code-cell} ipython3 -solution.decision_variables_df +solution.decision_variables_df() ``` この `pandas.DataFrame` オブジェクトを使うことで、例えば「アイテムをナップサックに入れるかどうか」という判断をまとめた表を pandas で簡単に作成できます: @@ -155,7 +155,7 @@ solution.decision_variables_df ```{code-cell} ipython3 import pandas as pd -df = solution.decision_variables_df +df = solution.decision_variables_df() pd.DataFrame.from_dict( { "アイテムの番号": df.index, @@ -179,10 +179,10 @@ assert np.isclose(solution.objective, expected) ### 制約条件の分析 -`constraints` プロパティは、制約条件の等号不等号、左辺の値 (`"value"`)、名前などの情報を含む `pandas.DataFrame` オブジェクトを返します: +`constraints_df()` メソッドは、制約条件の等号不等号、左辺の値 (`"value"`)、名前などの情報を含む `pandas.DataFrame` オブジェクトを返します: ```{code-cell} ipython3 -solution.constraints_df +solution.constraints_df() ``` 特に `"value"` は制約条件にどの程度の余裕があるのかを知るために便利です。今回のケースでは、0番目のアイテム $w_0$ の重さが `11`、3番目のアイテムの重さ $w_3$ が `35` であり、ナップサックの耐荷重 $W$ は `47` なので、重量制約 diff --git a/docs/ja/tutorial/switching_adapters.md b/docs/ja/tutorial/switching_adapters.md index 39fc349e7..5b52f11e5 100644 --- a/docs/ja/tutorial/switching_adapters.md +++ b/docs/ja/tutorial/switching_adapters.md @@ -101,7 +101,7 @@ plt.legend() import pandas decision_variables = pandas.concat([ - solution.decision_variables_df.assign(solver=solver) + solution.decision_variables_df().assign(solver=solver) for solver, solution in solutions.items() ]) decision_variables diff --git a/docs/ja/tutorial/tsp_sampling_with_openjij_adapter.md b/docs/ja/tutorial/tsp_sampling_with_openjij_adapter.md index 0a1facffb..f5e806666 100644 --- a/docs/ja/tutorial/tsp_sampling_with_openjij_adapter.md +++ b/docs/ja/tutorial/tsp_sampling_with_openjij_adapter.md @@ -157,14 +157,14 @@ sample_set.summary sample_set.summary_with_constraints ``` -より詳しい情報は `SampleSet.decision_variables_df` 及び `SampleSet.constraints_df` プロパティを使って取得できます。 +より詳しい情報は `SampleSet.decision_variables_df()` 及び `SampleSet.constraints_df()` メソッドを使って取得できます。 ```{code-cell} ipython3 -sample_set.decision_variables_df.head(2) +sample_set.decision_variables_df().head(2) ``` ```{code-cell} ipython3 -sample_set.constraints_df.head(2) +sample_set.constraints_df().head(2) ``` 得られたサンプルを取得するには `SampleSet.extract_decision_variables` メソッドを使います。これは `ommx.v1.DecisionVariables` を作る時に登録した `name` と `subscripts` を使ってサンプルを解釈します。例えば `sample_id=1` の `x` という名前の決定変数の値を取得するには次のようにすると `dict[subscripts, value]` の形で取得できます。 diff --git a/docs/ja/user_guide/capability_model.md b/docs/ja/user_guide/capability_model.md index 36efd654f..f7e611014 100644 --- a/docs/ja/user_guide/capability_model.md +++ b/docs/ja/user_guide/capability_model.md @@ -156,9 +156,9 @@ $$ | 元の制約型 | 除去先 | DataFrame | |---|---|---| -| OneHotConstraint | {attr}`~ommx.v1.Instance.removed_one_hot_constraints` | {attr}`~ommx.v1.Instance.removed_one_hot_constraints_df` | -| Sos1Constraint | {attr}`~ommx.v1.Instance.removed_sos1_constraints` | {attr}`~ommx.v1.Instance.removed_sos1_constraints_df` | -| IndicatorConstraint | {attr}`~ommx.v1.Instance.removed_indicator_constraints` | {attr}`~ommx.v1.Instance.removed_indicator_constraints_df` | +| OneHotConstraint | {attr}`~ommx.v1.Instance.removed_one_hot_constraints` | {meth}`~ommx.v1.Instance.removed_one_hot_constraints_df` | +| Sos1Constraint | {attr}`~ommx.v1.Instance.removed_sos1_constraints` | {meth}`~ommx.v1.Instance.removed_sos1_constraints_df` | +| IndicatorConstraint | {attr}`~ommx.v1.Instance.removed_indicator_constraints` | {meth}`~ommx.v1.Instance.removed_indicator_constraints_df` | それぞれのエントリ({class}`~ommx.v1.RemovedOneHotConstraint` / {class}`~ommx.v1.RemovedSos1Constraint` / {class}`~ommx.v1.RemovedIndicatorConstraint`)には `removed_reason` 文字列(例: `"ommx.Instance.convert_one_hot_to_constraint"`)が記録され、`removed_reason_parameters` に変換で新しく生成された通常制約の ID が格納されます。ID のキー名と形式は制約型ごとに異なります: diff --git a/docs/ja/user_guide/instance.md b/docs/ja/user_guide/instance.md index a43795651..68a297d56 100644 --- a/docs/ja/user_guide/instance.md +++ b/docs/ja/user_guide/instance.md @@ -95,7 +95,7 @@ print(f"{x1.id=}, {x1.name=}") 次に制約条件を見てみましょう ```{code-cell} ipython3 -instance.constraints_df +instance.constraints_df() ``` OMMXでは制約条件もIDで管理されます。このIDは決定変数のIDとは独立です。制約条件のIDは `Instance` に登録する際に決まります: [`Instance.from_components`](https://jij-inc.github.io/ommx/python/ommx/autoapi/ommx/v1/index.html#ommx.v1.Instance.from_components) に渡す `constraints` 辞書のキーがそのまま制約条件のIDになります。 diff --git a/docs/ja/user_guide/parametric_instance.md b/docs/ja/user_guide/parametric_instance.md index 122506a71..c7532c697 100644 --- a/docs/ja/user_guide/parametric_instance.md +++ b/docs/ja/user_guide/parametric_instance.md @@ -57,10 +57,10 @@ parametric_instance = ParametricInstance.from_components( ) ``` -`ommx.v1.Instance`と同様に `decision_variables_df` 及び `constraints_df` プロパティで決定変数と制約条件をDataFrameとして取得できますが、加えて `ommx.v1.ParametricInstance` には `parameters_df` プロパティがあります。これはパラメータの情報をDataFrameとして取得できます。 +`ommx.v1.Instance`と同様に `decision_variables_df()` 及び `constraints_df()` メソッドで決定変数と制約条件をDataFrameとして取得できますが、加えて `ommx.v1.ParametricInstance` には `parameters_df()` メソッドがあります。これはパラメータの情報をDataFrameとして取得できます。 ```{code-cell} ipython3 -parametric_instance.parameters_df +parametric_instance.parameters_df() ``` さて具体的なパラメータを指定してみましょう。それには `ParametricInstance.with_parameters` を使います。これは `ommx.v1.Parameter` のIDをキー、値を値とする辞書を引数に取ります。 diff --git a/docs/ja/user_guide/sample_set.md b/docs/ja/user_guide/sample_set.md index 0753d2f9f..6c91bcfb6 100644 --- a/docs/ja/user_guide/sample_set.md +++ b/docs/ja/user_guide/sample_set.md @@ -97,7 +97,7 @@ solution = sample_set.get(sample_id=0) assert isinstance(solution, Solution) print(f"{solution.objective=}") -solution.decision_variables_df +solution.decision_variables_df() ``` 最適解の取り出し @@ -109,7 +109,7 @@ solution = sample_set.best_feasible assert solution is not None # 最適な解が存在しない場合は None print(f"{solution.objective=}") -solution.decision_variables_df +solution.decision_variables_df() ``` もちろん、最小化問題の場合は最小の目的値のサンプルが返されます。 diff --git a/docs/ja/user_guide/solution.md b/docs/ja/user_guide/solution.md index 971c0d2d3..6779dbc96 100644 --- a/docs/ja/user_guide/solution.md +++ b/docs/ja/user_guide/solution.md @@ -55,13 +55,13 @@ solution = instance.evaluate({1: 1, 2: 0}) # x=1, y=0 生成された `ommx.v1.Soluiton` は `ommx.v1.Instance` からほとんどの情報を引き継ぎます。まず決定変数を見てみましょう。 ```{code-cell} ipython3 -solution.decision_variables_df +solution.decision_variables_df() ``` 必須であるIDと `kind`, `lower`, `upper` に加えて `name` などのメタデータも引き継ぎます。加えて `value` には `evaluate` で代入された値が追加されます。同様に制約条件にも `value` として評価値が追加されます。 ```{code-cell} ipython3 -solution.constraints_df +solution.constraints_df() ``` `objective` プロパティには目的関数の値が、`feasible` プロパティには制約条件を満たしているかどうかが格納されます。 diff --git a/docs/ja/user_guide/special_constraints.md b/docs/ja/user_guide/special_constraints.md index 9252911f2..897fbf6bb 100644 --- a/docs/ja/user_guide/special_constraints.md +++ b/docs/ja/user_guide/special_constraints.md @@ -180,19 +180,19 @@ assert set(instance_mix.sos1_constraints.keys()) == {1} | 制約型 | アクセサ(Solution) | |---|---| -| 通常制約 | {attr}`~ommx.v1.Solution.constraints_df` | -| Indicator | {attr}`~ommx.v1.Solution.indicator_constraints_df` | -| OneHot | {attr}`~ommx.v1.Solution.one_hot_constraints_df` | -| SOS1 | {attr}`~ommx.v1.Solution.sos1_constraints_df` | +| 通常制約 | {meth}`~ommx.v1.Solution.constraints_df` | +| Indicator | {meth}`~ommx.v1.Solution.indicator_constraints_df` | +| OneHot | {meth}`~ommx.v1.Solution.one_hot_constraints_df` | +| SOS1 | {meth}`~ommx.v1.Solution.sos1_constraints_df` | Indicator 制約の DataFrame には、`indicator_active` というカラムが含まれます。これにより「インジケータが OFF だった(制約は自明に満たされた)」ケースと「インジケータが ON で制約が本当に満たされた」ケースを区別できます。なお、Indicator 制約には双対変数の値は定義されない(条件付き制約に対する双対値は一般に well-defined ではない)ため、`dual_variable` は含まれません。 ### removed_reasons_df の分離 -通常制約の `removed_reason` は {attr}`~ommx.v1.Solution.constraints_df` のカラムとしては持たず、{attr}`~ommx.v1.Solution.removed_reasons_df` という別テーブルとして提供されます。必要なら join して使います。 +通常制約の `removed_reason` は {meth}`~ommx.v1.Solution.constraints_df` のカラムとしては持たず、{meth}`~ommx.v1.Solution.removed_reasons_df` という別テーブルとして提供されます。必要なら join して使います。 ```python -df = solution.constraints_df.join(solution.removed_reasons_df) +df = solution.constraints_df().join(solution.removed_reasons_df()) ``` Indicator・OneHot・SOS1 についても、それぞれ対応する `indicator_removed_reasons_df` / `one_hot_removed_reasons_df` / `sos1_removed_reasons_df` が {class}`~ommx.v1.Solution` および {class}`~ommx.v1.SampleSet` で利用できます。 From 430f7e534d76e090a3ef897adc664c45272a6d00 Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 17:24:07 +0900 Subject: [PATCH 09/11] =?UTF-8?q?docs(ja):=20fix=20instance.decision=5Fvar?= =?UTF-8?q?iables=20=E2=86=92=20decision=5Fvariables=5Fdf()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JA instance guide showed `instance.decision_variables` (the list-returning property) where the surrounding prose promised a `pandas.DataFrame`, while the EN counterpart was already correct as `instance.decision_variables_df()`. Pre-existing inconsistency surfaced by the Codex re-review of PR #846. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ja/user_guide/instance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ja/user_guide/instance.md b/docs/ja/user_guide/instance.md index 68a297d56..c3b59a801 100644 --- a/docs/ja/user_guide/instance.md +++ b/docs/ja/user_guide/instance.md @@ -63,7 +63,7 @@ instance.sense == Instance.MAXIMIZE 決定変数と制約条件については [`pandas.DataFrame`](https://pandas.pydata.org/pandas-docs/stable/reference/frame.html) の形式で取得できます ```{code-cell} ipython3 -instance.decision_variables +instance.decision_variables_df() ``` まず `kind` と `lower`, `upper` は数理モデルとして必須の情報です。 From 9af9f51addaa7427720ce72b572d8c98b7dccfd0 Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 17:34:30 +0900 Subject: [PATCH 10/11] docs: skip 2.x release notes from notebook execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same treatment as 1.x — the 2.x release notes pin code samples to API shapes that no longer compile under v3 (e.g. property-style `*_df` accessors, the bytes-on-element-types methods removed in #845). Build the pages but don't try to execute them. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/conf_base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/conf_base.py b/docs/conf_base.py index 7ca1dc46d..1c947d604 100644 --- a/docs/conf_base.py +++ b/docs/conf_base.py @@ -66,7 +66,10 @@ # Allow overriding execution mode via environment variable (e.g. OMMX_NB_EXECUTION=force) nb_execution_mode = os.environ.get("OMMX_NB_EXECUTION", "off") nb_execution_timeout = 300 -nb_execution_excludepatterns = ["release_note/ommx-1.*.md"] +nb_execution_excludepatterns = [ + "release_note/ommx-1.*.md", + "release_note/ommx-2.*.md", +] nb_execution_raise_on_error = True # -- Options for HTML output ------------------------------------------------- From 384a7200db4e940e5c9cc224039d93f57fabc878 Mon Sep 17 00:00:00 2001 From: Toshiki Teramura Date: Tue, 28 Apr 2026 17:54:56 +0900 Subject: [PATCH 11/11] Address Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Centralize `constraint_kind_collection!` macro in `crate::pandas`. Each host (Instance / ParametricInstance / Solution / SampleSet) now passes its own per-kind accessor names via the macro, so the kind dispatch shape lives in one place. - Fix `{attr}` cross-references that point to `*_df` accessors. Those accessors became methods, so the cross-references should use `{meth}` (8 sites in solution.rs / sample_set.rs). - Update `SampleSet.named_functions_df` docstring to document `parameters.{key}` columns (the implementation already emits them via `set_parameter_columns` — only the docstring claimed a single `parameters` column). Stub regenerate picks up all three changes in `_ommx_rust/__init__.pyi`. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/api_reference.json | 18 +-- python/ommx/ommx/_ommx_rust/__init__.pyi | 18 +-- python/ommx/src/instance.rs | 131 ++++++++++++--------- python/ommx/src/pandas.rs | 39 +++++++ python/ommx/src/parametric_instance.rs | 131 ++++++++++++--------- python/ommx/src/sample_set.rs | 143 ++++++++++++----------- python/ommx/src/solution.rs | 140 ++++++++++++---------- 7 files changed, 358 insertions(+), 262 deletions(-) diff --git a/docs/api/api_reference.json b/docs/api/api_reference.json index 48a72d8ae..7b56d2954 100644 --- a/docs/api/api_reference.json +++ b/docs/api/api_reference.json @@ -17600,7 +17600,7 @@ }, { "name": "indicator_removed_reasons_df", - "doc": "DataFrame of removed indicator constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`indicator_constraints_df` using the `id` index.", + "doc": "DataFrame of removed indicator constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {meth}`indicator_constraints_df` using the `id` index.", "signatures": [ { "parameters": [], @@ -17616,7 +17616,7 @@ }, { "name": "named_functions_df", - "doc": "DataFrame of named functions with per-sample value columns.\nStatic columns: id, used_ids, name, subscripts, description, parameters.\nDynamic columns: one per sample_id (int) with the function's evaluated value.", + "doc": "DataFrame of named functions with per-sample value columns.\nStatic columns: id, used_ids, name, subscripts, description, parameters.{key}.\nDynamic columns: one per sample_id (int) with the function's evaluated value.", "signatures": [ { "parameters": [ @@ -17714,7 +17714,7 @@ }, { "name": "one_hot_removed_reasons_df", - "doc": "DataFrame of removed one-hot constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`one_hot_constraints_df` using the `id` index.", + "doc": "DataFrame of removed one-hot constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {meth}`one_hot_constraints_df` using the `id` index.", "signatures": [ { "parameters": [], @@ -17730,7 +17730,7 @@ }, { "name": "removed_reasons_df", - "doc": "DataFrame of removed constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`constraints_df` using the `id` index.", + "doc": "DataFrame of removed constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {meth}`constraints_df` using the `id` index.", "signatures": [ { "parameters": [], @@ -17809,7 +17809,7 @@ }, { "name": "sos1_removed_reasons_df", - "doc": "DataFrame of removed SOS1 constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`sos1_constraints_df` using the `id` index.", + "doc": "DataFrame of removed SOS1 constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {meth}`sos1_constraints_df` using the `id` index.", "signatures": [ { "parameters": [], @@ -19746,7 +19746,7 @@ }, { "name": "indicator_removed_reasons_df", - "doc": "DataFrame of removed indicator constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`indicator_constraints_df` using the `id` index.", + "doc": "DataFrame of removed indicator constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {meth}`indicator_constraints_df` using the `id` index.", "signatures": [ { "parameters": [], @@ -19844,7 +19844,7 @@ }, { "name": "one_hot_removed_reasons_df", - "doc": "DataFrame of removed one-hot constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`one_hot_constraints_df` using the `id` index.", + "doc": "DataFrame of removed one-hot constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {meth}`one_hot_constraints_df` using the `id` index.", "signatures": [ { "parameters": [], @@ -19860,7 +19860,7 @@ }, { "name": "removed_reasons_df", - "doc": "DataFrame of removed constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`constraints_df` on the `id` index.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[\n... (x[0] + x[1] == 1).set_id(10),\n... (x[1] + x[2] == 1).set_id(20),\n... ],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.relax_constraint(10, \"test_reason\")\n>>> solution = instance.evaluate({0: 1, 1: 0, 2: 1})\n```\n\n`removed_reasons_df` contains only removed constraints:\n\n```python\n>>> solution.removed_reasons_df()\n removed_reason\nid\n10 test_reason\n```\n\nJoin with `constraints_df` to get full information:\n\n```python\n>>> df = solution.constraints_df().join(solution.removed_reasons_df())\n>>> df[[\"value\", \"removed_reason\"]]\n value removed_reason\nid\n10 0.0 test_reason\n20 0.0 NaN\n```", + "doc": "DataFrame of removed constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {meth}`constraints_df` on the `id` index.\n\n# Examples\n\n```python\n>>> from ommx.v1 import Instance, DecisionVariable\n>>> x = [DecisionVariable.binary(i) for i in range(3)]\n>>> instance = Instance.from_components(\n... decision_variables=x,\n... objective=sum(x),\n... constraints=[\n... (x[0] + x[1] == 1).set_id(10),\n... (x[1] + x[2] == 1).set_id(20),\n... ],\n... sense=Instance.MAXIMIZE,\n... )\n>>> instance.relax_constraint(10, \"test_reason\")\n>>> solution = instance.evaluate({0: 1, 1: 0, 2: 1})\n```\n\n`removed_reasons_df` contains only removed constraints:\n\n```python\n>>> solution.removed_reasons_df()\n removed_reason\nid\n10 test_reason\n```\n\nJoin with `constraints_df` to get full information:\n\n```python\n>>> df = solution.constraints_df().join(solution.removed_reasons_df())\n>>> df[[\"value\", \"removed_reason\"]]\n value removed_reason\nid\n10 0.0 test_reason\n20 0.0 NaN\n```", "signatures": [ { "parameters": [], @@ -19958,7 +19958,7 @@ }, { "name": "sos1_removed_reasons_df", - "doc": "DataFrame of removed SOS1 constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {attr}`sos1_constraints_df` using the `id` index.", + "doc": "DataFrame of removed SOS1 constraint reasons.\n\nColumns: id (index), removed_reason, removed_reason.{key}\n\nCan be joined with {meth}`sos1_constraints_df` using the `id` index.", "signatures": [ { "parameters": [], diff --git a/python/ommx/ommx/_ommx_rust/__init__.pyi b/python/ommx/ommx/_ommx_rust/__init__.pyi index 45bf07477..25d4e2382 100644 --- a/python/ommx/ommx/_ommx_rust/__init__.pyi +++ b/python/ommx/ommx/_ommx_rust/__init__.pyi @@ -4173,7 +4173,7 @@ class SampleSet: Columns: id (index), removed_reason, removed_reason.{key} - Can be joined with {attr}`constraints_df` using the `id` index. + Can be joined with {meth}`constraints_df` using the `id` index. """ def indicator_constraints_df( self, include: typing.Optional[typing.Sequence[builtins.str]] = None @@ -4189,7 +4189,7 @@ class SampleSet: Columns: id (index), removed_reason, removed_reason.{key} - Can be joined with {attr}`indicator_constraints_df` using the `id` index. + Can be joined with {meth}`indicator_constraints_df` using the `id` index. """ def one_hot_constraints_df( self, include: typing.Optional[typing.Sequence[builtins.str]] = None @@ -4205,7 +4205,7 @@ class SampleSet: Columns: id (index), removed_reason, removed_reason.{key} - Can be joined with {attr}`one_hot_constraints_df` using the `id` index. + Can be joined with {meth}`one_hot_constraints_df` using the `id` index. """ def sos1_constraints_df( self, include: typing.Optional[typing.Sequence[builtins.str]] = None @@ -4221,14 +4221,14 @@ class SampleSet: Columns: id (index), removed_reason, removed_reason.{key} - Can be joined with {attr}`sos1_constraints_df` using the `id` index. + Can be joined with {meth}`sos1_constraints_df` using the `id` index. """ def named_functions_df( self, include: typing.Optional[typing.Sequence[builtins.str]] = None ) -> pandas.DataFrame: r""" DataFrame of named functions with per-sample value columns. - Static columns: id, used_ids, name, subscripts, description, parameters. + Static columns: id, used_ids, name, subscripts, description, parameters.{key}. Dynamic columns: one per sample_id (int) with the function's evaluated value. """ def constraint_metadata_df( @@ -4755,7 +4755,7 @@ class Solution: Columns: id (index), removed_reason, removed_reason.{key} - Can be joined with {attr}`constraints_df` on the `id` index. + Can be joined with {meth}`constraints_df` on the `id` index. # Examples @@ -4809,7 +4809,7 @@ class Solution: Columns: id (index), removed_reason, removed_reason.{key} - Can be joined with {attr}`indicator_constraints_df` using the `id` index. + Can be joined with {meth}`indicator_constraints_df` using the `id` index. """ def one_hot_constraints_df( self, include: typing.Optional[typing.Sequence[builtins.str]] = None @@ -4825,7 +4825,7 @@ class Solution: Columns: id (index), removed_reason, removed_reason.{key} - Can be joined with {attr}`one_hot_constraints_df` using the `id` index. + Can be joined with {meth}`one_hot_constraints_df` using the `id` index. """ def sos1_constraints_df( self, include: typing.Optional[typing.Sequence[builtins.str]] = None @@ -4841,7 +4841,7 @@ class Solution: Columns: id (index), removed_reason, removed_reason.{key} - Can be joined with {attr}`sos1_constraints_df` using the `id` index. + Can be joined with {meth}`sos1_constraints_df` using the `id` index. """ def named_functions_df( self, include: typing.Optional[typing.Sequence[builtins.str]] = None diff --git a/python/ommx/src/instance.rs b/python/ommx/src/instance.rs index 06a951d0d..45e6e1051 100644 --- a/python/ommx/src/instance.rs +++ b/python/ommx/src/instance.rs @@ -1,6 +1,7 @@ use crate::{ pandas::{ - constraint_id_col, entries_to_dataframe, parse_constraint_kind, ConstraintKind, PyDataFrame, + constraint_id_col, constraint_kind_collection, entries_to_dataframe, parse_constraint_kind, + PyDataFrame, }, Constraint, DecisionVariable, Function, NamedFunction, ParametricInstance, RemovedConstraint, Rng, SampleSet, Samples, Sense, Solution, State, VariableBound, @@ -15,32 +16,6 @@ use pyo3::{ }; use std::collections::{BTreeMap, BTreeSet, HashMap}; -/// Bind `coll` to the per-kind constraint collection on `self.inner` and -/// evaluate `body`. Used by the four `constraint_*_df` sidecar accessors so -/// the four `ConstraintKind` arms collapse to a single call site. -macro_rules! constraint_kind_collection { - ($self:expr, $kind:expr, |$coll:ident| $body:block) => { - match $kind { - ConstraintKind::Regular => { - let $coll = $self.inner.constraint_collection(); - $body - } - ConstraintKind::Indicator => { - let $coll = $self.inner.indicator_constraint_collection(); - $body - } - ConstraintKind::OneHot => { - let $coll = $self.inner.one_hot_constraint_collection(); - $body - } - ConstraintKind::Sos1 => { - let $coll = $self.inner.sos1_constraint_collection(); - $body - } - } - }; -} - /// Optimization problem instance. /// /// Note that this class also contains annotations like {attr}`~ommx.v1.Instance.title` which are not contained in protobuf message but stored in OMMX artifact. @@ -2008,14 +1983,24 @@ impl Instance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraint_collection, + indicator_constraint_collection, + one_hot_constraint_collection, + sos1_constraint_collection + ], + |coll| { + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ) } /// Constraint parameters DataFrame (long format). @@ -2030,14 +2015,24 @@ impl Instance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraint_collection, + indicator_constraint_collection, + one_hot_constraint_collection, + sos1_constraint_collection + ], + |coll| { + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ) } /// Constraint provenance DataFrame (long format). @@ -2052,14 +2047,24 @@ impl Instance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraint_collection, + indicator_constraint_collection, + one_hot_constraint_collection, + sos1_constraint_collection + ], + |coll| { + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ) } /// Removed-constraint reasons DataFrame (long format). @@ -2075,13 +2080,23 @@ impl Instance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_removed_reasons_dataframe( - py, - coll.removed().iter().map(|(id, (_, r))| (*id, r)), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraint_collection, + indicator_constraint_collection, + one_hot_constraint_collection, + sos1_constraint_collection + ], + |coll| { + crate::pandas::constraint_removed_reasons_dataframe( + py, + coll.removed().iter().map(|(id, (_, r))| (*id, r)), + id_col, + ) + } + ) } /// Decision-variable metadata DataFrame (id-indexed wide format). diff --git a/python/ommx/src/pandas.rs b/python/ommx/src/pandas.rs index 467e4184c..5e11ae367 100644 --- a/python/ommx/src/pandas.rs +++ b/python/ommx/src/pandas.rs @@ -157,6 +157,45 @@ pub fn constraint_id_col(kind: ConstraintKind) -> &'static str { } } +/// Dispatch on `ConstraintKind` and bind `coll` to the per-kind constraint +/// collection on `$container`. Used by the four `constraint_*_df` sidecar +/// accessors so the four `ConstraintKind` arms collapse to a single call site. +/// +/// Each host (`Instance` / `ParametricInstance` / `Solution` / `SampleSet`) +/// passes its own accessor names because the underlying collection types +/// differ — `ConstraintCollection` for Instance/ParametricInstance, +/// `EvaluatedConstraintCollection` for Solution, `SampledConstraintCollection` +/// for SampleSet. Centralising the match shape here avoids drift when adding a +/// new constraint kind. +macro_rules! constraint_kind_collection { + ( + $container:expr, $kind:expr, + [$regular:ident, $indicator:ident, $one_hot:ident, $sos1:ident], + |$coll:ident| $body:block + ) => { + match $kind { + $crate::pandas::ConstraintKind::Regular => { + let $coll = $container.$regular(); + $body + } + $crate::pandas::ConstraintKind::Indicator => { + let $coll = $container.$indicator(); + $body + } + $crate::pandas::ConstraintKind::OneHot => { + let $coll = $container.$one_hot(); + $body + } + $crate::pandas::ConstraintKind::Sos1 => { + let $coll = $container.$sos1(); + $body + } + } + }; +} + +pub(crate) use constraint_kind_collection; + // --------------------------------------------------------------------------- // Sidecar DataFrame builders // diff --git a/python/ommx/src/parametric_instance.rs b/python/ommx/src/parametric_instance.rs index 2c9a47991..119f5edda 100644 --- a/python/ommx/src/parametric_instance.rs +++ b/python/ommx/src/parametric_instance.rs @@ -1,6 +1,7 @@ use crate::{ pandas::{ - constraint_id_col, entries_to_dataframe, parse_constraint_kind, ConstraintKind, PyDataFrame, + constraint_id_col, constraint_kind_collection, entries_to_dataframe, parse_constraint_kind, + PyDataFrame, }, Constraint, DecisionVariable, Function, Instance, NamedFunction, Parameter, RemovedConstraint, Sense, @@ -10,32 +11,6 @@ use ommx::{ConstraintID, NamedFunctionID, VariableID}; use pyo3::{exceptions::PyKeyError, prelude::*, types::PyBytes, Bound, PyAny}; use std::collections::{BTreeMap, BTreeSet, HashMap}; -/// Bind `coll` to the per-kind constraint collection on `self.inner` and -/// evaluate `body`. Mirror of the macro in `instance.rs` — `ParametricInstance` -/// shares the same `*_constraint_collection()` accessor names as `Instance`. -macro_rules! constraint_kind_collection { - ($self:expr, $kind:expr, |$coll:ident| $body:block) => { - match $kind { - ConstraintKind::Regular => { - let $coll = $self.inner.constraint_collection(); - $body - } - ConstraintKind::Indicator => { - let $coll = $self.inner.indicator_constraint_collection(); - $body - } - ConstraintKind::OneHot => { - let $coll = $self.inner.one_hot_constraint_collection(); - $body - } - ConstraintKind::Sos1 => { - let $coll = $self.inner.sos1_constraint_collection(); - $body - } - } - }; -} - #[pyo3_stub_gen::derive::gen_stub_pyclass] #[pyclass] #[derive(Clone)] @@ -447,14 +422,24 @@ impl ParametricInstance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraint_collection, + indicator_constraint_collection, + one_hot_constraint_collection, + sos1_constraint_collection + ], + |coll| { + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ) } /// Constraint parameters DataFrame (long format). @@ -466,14 +451,24 @@ impl ParametricInstance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraint_collection, + indicator_constraint_collection, + one_hot_constraint_collection, + sos1_constraint_collection + ], + |coll| { + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ) } /// Constraint provenance DataFrame (long format). @@ -485,14 +480,24 @@ impl ParametricInstance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.active().keys().chain(coll.removed().keys()).copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraint_collection, + indicator_constraint_collection, + one_hot_constraint_collection, + sos1_constraint_collection + ], + |coll| { + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.active().keys().chain(coll.removed().keys()).copied(), + id_col, + ) + } + ) } /// Removed-constraint reasons DataFrame (long format). @@ -504,13 +509,23 @@ impl ParametricInstance { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_removed_reasons_dataframe( - py, - coll.removed().iter().map(|(id, (_, r))| (*id, r)), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraint_collection, + indicator_constraint_collection, + one_hot_constraint_collection, + sos1_constraint_collection + ], + |coll| { + crate::pandas::constraint_removed_reasons_dataframe( + py, + coll.removed().iter().map(|(id, (_, r))| (*id, r)), + id_col, + ) + } + ) } /// Decision-variable metadata DataFrame (id-indexed). diff --git a/python/ommx/src/sample_set.rs b/python/ommx/src/sample_set.rs index 91de5416d..82a8bf4d1 100644 --- a/python/ommx/src/sample_set.rs +++ b/python/ommx/src/sample_set.rs @@ -1,7 +1,7 @@ use crate::{ pandas::{ - constraint_id_col, entries_to_dataframe, parse_constraint_kind, - sorted_entries_to_dataframe, ConstraintKind, PyDataFrame, WithSampleIds, + constraint_id_col, constraint_kind_collection, entries_to_dataframe, parse_constraint_kind, + sorted_entries_to_dataframe, PyDataFrame, WithSampleIds, }, Solution, }; @@ -13,33 +13,6 @@ use pyo3::{ }; use std::collections::{BTreeMap, BTreeSet, HashMap}; -/// Bind `coll` to the per-kind sampled constraint collection on -/// `self.inner` and evaluate `body`. `SampledCollection.inner()` already -/// includes removed ids alongside active ones, so the body iterates -/// `coll.inner().keys()` (no `.chain(removed_reasons())`). -macro_rules! constraint_kind_collection { - ($self:expr, $kind:expr, |$coll:ident| $body:block) => { - match $kind { - ConstraintKind::Regular => { - let $coll = $self.inner.constraints(); - $body - } - ConstraintKind::Indicator => { - let $coll = $self.inner.indicator_constraints(); - $body - } - ConstraintKind::OneHot => { - let $coll = $self.inner.one_hot_constraints(); - $body - } - ConstraintKind::Sos1 => { - let $coll = $self.inner.sos1_constraints(); - $body - } - } - }; -} - /// The output of sampling-based optimization algorithms, e.g. simulated annealing (SA). /// /// - Similar to `Solution` rather than the raw `State` message. @@ -698,7 +671,7 @@ impl SampleSet { /// /// Columns: id (index), removed_reason, removed_reason.{key} /// - /// Can be joined with {attr}`constraints_df` using the `id` index. + /// Can be joined with {meth}`constraints_df` using the `id` index. pub fn removed_reasons_df<'py>(&self, py: Python<'py>) -> PyResult> { use crate::pandas::{IncludeFlags, RemovedReasonEntry}; entries_to_dataframe( @@ -758,7 +731,7 @@ impl SampleSet { /// /// Columns: id (index), removed_reason, removed_reason.{key} /// - /// Can be joined with {attr}`indicator_constraints_df` using the `id` index. + /// Can be joined with {meth}`indicator_constraints_df` using the `id` index. pub fn indicator_removed_reasons_df<'py>( &self, py: Python<'py>, @@ -821,7 +794,7 @@ impl SampleSet { /// /// Columns: id (index), removed_reason, removed_reason.{key} /// - /// Can be joined with {attr}`one_hot_constraints_df` using the `id` index. + /// Can be joined with {meth}`one_hot_constraints_df` using the `id` index. pub fn one_hot_removed_reasons_df<'py>( &self, py: Python<'py>, @@ -884,7 +857,7 @@ impl SampleSet { /// /// Columns: id (index), removed_reason, removed_reason.{key} /// - /// Can be joined with {attr}`sos1_constraints_df` using the `id` index. + /// Can be joined with {meth}`sos1_constraints_df` using the `id` index. pub fn sos1_removed_reasons_df<'py>( &self, py: Python<'py>, @@ -906,7 +879,7 @@ impl SampleSet { } /// DataFrame of named functions with per-sample value columns. - /// Static columns: id, used_ids, name, subscripts, description, parameters. + /// Static columns: id, used_ids, name, subscripts, description, parameters.{key}. /// Dynamic columns: one per sample_id (int) with the function's evaluated value. #[pyo3(signature = (include = None))] pub fn named_functions_df<'py>( @@ -941,14 +914,24 @@ impl SampleSet { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraints, + indicator_constraints, + one_hot_constraints, + sos1_constraints + ], + |coll| { + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + } + ) } /// Constraint parameters DataFrame (long format). @@ -960,14 +943,24 @@ impl SampleSet { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraints, + indicator_constraints, + one_hot_constraints, + sos1_constraints + ], + |coll| { + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + } + ) } /// Constraint provenance DataFrame (long format). @@ -979,14 +972,24 @@ impl SampleSet { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraints, + indicator_constraints, + one_hot_constraints, + sos1_constraints + ], + |coll| { + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + } + ) } /// Removed-constraint reasons DataFrame (long format). @@ -998,13 +1001,23 @@ impl SampleSet { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_removed_reasons_dataframe( - py, - coll.removed_reasons().iter().map(|(id, r)| (*id, r)), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + constraints, + indicator_constraints, + one_hot_constraints, + sos1_constraints + ], + |coll| { + crate::pandas::constraint_removed_reasons_dataframe( + py, + coll.removed_reasons().iter().map(|(id, r)| (*id, r)), + id_col, + ) + } + ) } /// Decision-variable metadata DataFrame (id-indexed). diff --git a/python/ommx/src/solution.rs b/python/ommx/src/solution.rs index 7bfa0855f..d25440bf9 100644 --- a/python/ommx/src/solution.rs +++ b/python/ommx/src/solution.rs @@ -1,5 +1,6 @@ use crate::pandas::{ - constraint_id_col, entries_to_dataframe, parse_constraint_kind, ConstraintKind, PyDataFrame, + constraint_id_col, constraint_kind_collection, entries_to_dataframe, parse_constraint_kind, + PyDataFrame, }; use anyhow::Result; use pyo3::{ @@ -11,33 +12,6 @@ use pyo3::{ }; use std::collections::{BTreeSet, HashMap}; -/// Bind `coll` to the per-kind evaluated constraint collection on -/// `self.inner` and evaluate `body`. `EvaluatedCollection.inner()` already -/// includes removed ids alongside active ones, so the body iterates -/// `coll.inner().keys()` (no `.chain(removed_reasons())`). -macro_rules! constraint_kind_collection { - ($self:expr, $kind:expr, |$coll:ident| $body:block) => { - match $kind { - ConstraintKind::Regular => { - let $coll = $self.inner.evaluated_constraints(); - $body - } - ConstraintKind::Indicator => { - let $coll = $self.inner.evaluated_indicator_constraints(); - $body - } - ConstraintKind::OneHot => { - let $coll = $self.inner.evaluated_one_hot_constraints(); - $body - } - ConstraintKind::Sos1 => { - let $coll = $self.inner.evaluated_sos1_constraints(); - $body - } - } - }; -} - /// Idiomatic wrapper of `ommx.v1.Solution` protobuf message. /// /// This also contains annotations not contained in protobuf message, and will be stored in OMMX artifact. @@ -560,7 +534,7 @@ impl Solution { /// /// Columns: id (index), removed_reason, removed_reason.{key} /// - /// Can be joined with {attr}`constraints_df` on the `id` index. + /// Can be joined with {meth}`constraints_df` on the `id` index. /// /// # Examples /// @@ -654,7 +628,7 @@ impl Solution { /// /// Columns: id (index), removed_reason, removed_reason.{key} /// - /// Can be joined with {attr}`indicator_constraints_df` using the `id` index. + /// Can be joined with {meth}`indicator_constraints_df` using the `id` index. pub fn indicator_removed_reasons_df<'py>( &self, py: Python<'py>, @@ -713,7 +687,7 @@ impl Solution { /// /// Columns: id (index), removed_reason, removed_reason.{key} /// - /// Can be joined with {attr}`one_hot_constraints_df` using the `id` index. + /// Can be joined with {meth}`one_hot_constraints_df` using the `id` index. pub fn one_hot_removed_reasons_df<'py>( &self, py: Python<'py>, @@ -768,7 +742,7 @@ impl Solution { /// /// Columns: id (index), removed_reason, removed_reason.{key} /// - /// Can be joined with {attr}`sos1_constraints_df` using the `id` index. + /// Can be joined with {meth}`sos1_constraints_df` using the `id` index. pub fn sos1_removed_reasons_df<'py>( &self, py: Python<'py>, @@ -818,14 +792,24 @@ impl Solution { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_metadata_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + evaluated_constraints, + evaluated_indicator_constraints, + evaluated_one_hot_constraints, + evaluated_sos1_constraints + ], + |coll| { + crate::pandas::constraint_metadata_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + } + ) } /// Constraint parameters DataFrame (long format). @@ -837,14 +821,24 @@ impl Solution { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_parameters_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + evaluated_constraints, + evaluated_indicator_constraints, + evaluated_one_hot_constraints, + evaluated_sos1_constraints + ], + |coll| { + crate::pandas::constraint_parameters_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + } + ) } /// Constraint provenance DataFrame (long format). @@ -856,14 +850,24 @@ impl Solution { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_provenance_dataframe( - py, - coll.metadata(), - coll.inner().keys().copied(), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + evaluated_constraints, + evaluated_indicator_constraints, + evaluated_one_hot_constraints, + evaluated_sos1_constraints + ], + |coll| { + crate::pandas::constraint_provenance_dataframe( + py, + coll.metadata(), + coll.inner().keys().copied(), + id_col, + ) + } + ) } /// Removed-constraint reasons DataFrame (long format). @@ -875,13 +879,23 @@ impl Solution { ) -> PyResult> { let kind = parse_constraint_kind(&kind)?; let id_col = constraint_id_col(kind); - constraint_kind_collection!(self, kind, |coll| { - crate::pandas::constraint_removed_reasons_dataframe( - py, - coll.removed_reasons().iter().map(|(id, r)| (*id, r)), - id_col, - ) - }) + constraint_kind_collection!( + self.inner, + kind, + [ + evaluated_constraints, + evaluated_indicator_constraints, + evaluated_one_hot_constraints, + evaluated_sos1_constraints + ], + |coll| { + crate::pandas::constraint_removed_reasons_dataframe( + py, + coll.removed_reasons().iter().map(|(id, r)| (*id, r)), + id_col, + ) + } + ) } /// Decision-variable metadata DataFrame (id-indexed).