ommx 3.0.0-beta.2

Open Mathematical prograMming eXchange (OMMX)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
# Rust SDK Migration Guide

This document covers migration of the OMMX Rust SDK (`ommx` crate) across major versions.

- [v3 (Stage Pattern)]#rust-sdk-v3-stage-pattern-migration-guide — Constraint lifecycle stage parameterization
- [v3 (Artifact API)]#rust-sdk-v3-artifact-api-migration-guide — Local registry / archive draft split and renames

---

# Rust SDK v3 Stage Pattern Migration Guide

This section covers the migration to stage-parameterized constraints
landed in `3.0.0-alpha.1`.

## Overview

`Constraint` is now generic over a lifecycle stage, its `ConstraintID`
lives on the enclosing collection key rather than on the struct itself,
and constraint context (`ModelingLabel` plus constraint `provenance`)
lives in a Struct-of-Arrays store on the enclosing
collection — not on the per-constraint struct:

```rust,ignore
pub struct Constraint<S: Stage<Self> = Created> {
    pub equality: Equality,
    pub stage: S::Data,
}
```

Three lifecycle stages are defined:

| Type alias | Full type | Stage data |
|---|---|---|
| `Constraint` | `Constraint<Created>` | `CreatedData { function }` |
| `EvaluatedConstraint` | `Constraint<Evaluated>` | `EvaluatedData { evaluated_value, feasible, ... }` |
| `SampledConstraint` | `Constraint<SampledStage>` | `SampledData { evaluated_values, feasible, ... }` |

Removed constraints are managed at the collection level —
`ConstraintCollection` stores them as `(Constraint<Created>, RemovedReason)`
pairs. "Removed" is not itself a stage.

`DecisionVariable`, `IndicatorConstraint<S>`, `OneHotConstraint<S>`,
`Sos1Constraint<S>`, and `NamedFunction` got the same SoA treatment —
each lost its inline label/context fields, and the per-host label/context store
is queried through narrow per-collection accessors on `Instance` /
`ParametricInstance` (see [Modeling labels and constraint context](#modeling-labels-and-constraint-context)).
Decision variables and named functions additionally follow the table-owned ID
rule: the row data no longer stores its own ID.

## Breaking Changes

### 1. Constraint Field Access

Fields that were previously on the struct directly are now split
between common fields and stage-specific data. The `id` field is gone
entirely — look it up via the enclosing `BTreeMap` key.

**Common fields**:
```rust,ignore
// ❌ Before
constraint.id        // ConstraintID

// ✅ After — IDs live on collection keys
for (id, constraint) in instance.constraints() {
    // `id: &ConstraintID`, `constraint: &Constraint`
}

// ✅ Unchanged
constraint.equality  // Equality
```

**Modeling-label and provenance fields** (moved off the constraint struct entirely; query the
host's per-collection context store):
```rust,ignore
// ❌ Before (v2 — per-constraint inline)
constraint.name
constraint.subscripts
constraint.parameters
constraint.description

// ❌ Earlier v3 alpha (briefly: a `metadata` field on the struct) — also gone
constraint.metadata.name

// ✅ After — constraint context lives in the SoA store on the enclosing collection
let store = instance.constraint_context();   // &ConstraintContextStore<ConstraintID>
store.name(id)         // Option<&str>
store.subscripts(id)   // &[i64]
store.parameters(id)   // &FnvHashMap<String, String>
store.description(id)  // Option<&str>
store.provenance(id)   // &[Provenance]

// One-shot owned reconstruction matching the pre-SoA fields
let context: ConstraintContext = store.collect_for(id);
```

The same shape applies to indicator / one-hot / sos1 constraints
(`indicator_constraint_context()`, …) and to decision variables
(`variable_labels()` exposes a `VariableLabelStore` without
`provenance`). For named functions the parallel accessor is
`named_function_labels()` returning a `NamedFunctionLabelStore`.

**Created stage** — function access:
```rust,ignore
// ❌ Before
constraint.function

// ✅ After (method)
constraint.function()       // &Function
constraint.function_mut()   // &mut Function

// ✅ After (direct field)
constraint.stage.function   // Function
```

**Evaluated stage** — evaluation result access:
```rust,ignore
// ❌ Before (getset methods)
*evaluated.evaluated_value()
*evaluated.feasible()
evaluated.dual_variable
evaluated.removed_reason()
evaluated.used_decision_variable_ids()

// ✅ After (direct stage field access)
evaluated.stage.evaluated_value
evaluated.stage.feasible
evaluated.stage.dual_variable
evaluated.stage.used_decision_variable_ids
```

`removed_reason` is no longer on evaluated/sampled constraints — it's managed by `EvaluatedCollection` / `SampledCollection` via `collection.removed_reasons()` and `collection.is_removed(&id)`.

**Sampled stage** — same pattern:
```rust,ignore
// ❌ Before
*sampled.evaluated_values()
sampled.feasible()
sampled.dual_variables

// ✅ After
sampled.stage.evaluated_values
sampled.stage.feasible
sampled.stage.dual_variables
```

### 2. Struct Literal Construction

**Constraint (Created)**:
```rust,ignore
// ❌ Before
Constraint {
    id: ConstraintID::from(1),
    function,
    equality: Equality::EqualToZero,
    name: None,
    subscripts: Vec::new(),
    parameters: FnvHashMap::default(),
    description: None,
}

// ✅ After — no `id` and no inline label/context
Constraint {
    equality: Equality::EqualToZero,
    stage: CreatedData { function },
}

// ✅ Factory methods no longer take an ID
Constraint::equal_to_zero(function)
Constraint::less_than_or_equal_to_zero(function)

// ✅ Insertion via the host's invariant-safe entry point — picks an
// unused id, drains the (optional) context into the SoA store,
// validates required_ids, returns the assigned id. `add_constraint`,
// `relax_constraint`, and `restore_constraint` all take `&mut self`,
// so `instance` must be a `mut` binding (or accessed via `&mut Instance`).
let id = instance.add_constraint(
    Constraint::equal_to_zero(function),
    ConstraintContext {
        label: ModelingLabel {
            name: Some("demand_balance".into()),
            ..Default::default()
        },
        ..Default::default()
    },
)?;

// `relax_constraint` / `restore_constraint` move id between active and
// removed; context stays in place. There is no `constraint_collection_mut()`
// — the raw map mutators on `ConstraintCollection<T>` are `pub(crate)`.
```

**Removed constraints** are no longer constructed as `Constraint<Removed>`. They are stored as `(Constraint<Created>, RemovedReason)` tuples in `ConstraintCollection`:
```rust,ignore
// ❌ Before (v2)
let removed = RemovedConstraint {
    constraint: inner_constraint,
    removed_reason: "reason".to_string(),
    removed_reason_parameters: Default::default(),
};

// ✅ After — use Instance::relax_constraint() or store tuples directly
instance.relax_constraint(id, "reason".to_string(), [])?;

// Or if constructing directly:
let removed: (Constraint, RemovedReason) = (
    constraint,
    RemovedReason {
        reason: "reason".to_string(),
        parameters: Default::default(),
    },
);
```

**EvaluatedConstraint**:
```rust,ignore
// ❌ Before
EvaluatedConstraint {
    id, equality, metadata,
    evaluated_value,
    feasible,
    dual_variable: None,
    used_decision_variable_ids,
    removed_reason: None,
    removed_reason_parameters: FnvHashMap::default(),
}

// ✅ After — no `id`, no inline label/context; insert with the key when
// storing. Context for the id rides on the parent
// `EvaluatedCollection<T>::context` SoA store.
Constraint {
    equality,
    stage: EvaluatedData {
        evaluated_value,
        feasible,
        dual_variable: None,
        used_decision_variable_ids,
    },
}
```

### 3. RemovedConstraint Removed

`RemovedConstraint` type alias no longer exists. Removed constraints are stored as `(Constraint<Created>, RemovedReason)` tuples in `ConstraintCollection`.

```rust,ignore
// ❌ Before (v2)
removed.constraint.id
removed.constraint.equality
removed.constraint.function
removed.removed_reason              // String
removed.removed_reason_parameters   // FnvHashMap<String, String>

// ✅ After — access via the tuple; the ID comes from the map key
let (constraint, reason) = collection.removed().get(&id).unwrap();
// id is the BTreeMap key you looked it up by
constraint.equality
constraint.function()
reason.reason
reason.parameters
```

### 4. RemovedReason Struct

`removed_reason: String` + `removed_reason_parameters: FnvHashMap<String, String>` are consolidated into a single struct:

```rust,ignore
pub struct RemovedReason {
    pub reason: String,
    pub parameters: FnvHashMap<String, String>,
}
```

`RemovedReason` is stored at the collection level, not on individual constraints:
- `ConstraintCollection.removed()``&BTreeMap<ID, (T::Created, RemovedReason)>`
- `EvaluatedCollection.removed_reasons()``&BTreeMap<ID, RemovedReason>`
- `SampledCollection.removed_reasons()``&BTreeMap<ID, RemovedReason>`

### 5. Instance Fields

`Instance.constraints` and `Instance.removed_constraints` fields are replaced by `constraint_collection: ConstraintCollection<Constraint>`.

Accessor methods are preserved for backward compatibility:
```rust,ignore
// These still work
instance.constraints()           // &BTreeMap<ConstraintID, Constraint>
instance.removed_constraints()   // &BTreeMap<ConstraintID, (Constraint, RemovedReason)>

// New: access the full collection
instance.constraint_collection() // &ConstraintCollection<Constraint>
```

For mutable access, downstream code goes through invariant-safe
`Instance` / `ParametricInstance` methods (`add_constraint`,
`insert_constraint`, `relax_constraint`, `restore_constraint`, …) —
these validate that every `id` referenced by the constraint exists in
`decision_variables` and keep the active / removed maps disjoint.
Crate-internal transformation code also routes through operation-level
collection effects rather than raw active / removed / context map mutation.

### 6. getset Removal

`EvaluatedConstraint` and `SampledConstraint` no longer use the `getset` crate. All fields are accessed directly via `self.equality` and `self.stage.*`. (`self.id` and `self.metadata` no longer exist on the struct — see [Modeling labels and constraint context](#modeling-labels-and-constraint-context) and the constraint-field-access section above.)

Methods like `.id()`, `.equality()`, `.evaluated_value()`, `.feasible()` are **removed**. Use field access instead.

### 7. Error Surface Call-Site Rewrites

See the [3.0 release note](crate::doc::release_note::v3_0) for the
rationale and the [error handling tutorial](crate::doc::tutorial::error_handling)
for the `ommx::Result` / signal-type / fail-site-macro story. This
section only lists the mechanical call-site rewrites you need to apply
when upgrading a crate that was on v2.

**Deleted error enums.** The types below no longer exist. Match arms
that inspected their variants should switch to string inspection (if
you really cared) or just propagate via `?`:

- `ommx::InstanceError`
- `ommx::MpsParseError`, `ommx::MpsWriteError`
- `ommx::StateValidationError`, `ommx::LogEncodingError`
- `ommx::UnknownSampleIDError` — replaced by `Option<T>` on key-lookup methods
- `ommx::ParseErrorReason` — the variant enum inside the old `ommx::QplibParseError`

```rust,ignore
// ❌ Before (v2)
match decode(bytes) {
    Err(InstanceError::DuplicateConstraintID(id)) => { ... }
    Err(InstanceError::UndefinedVariable(v)) => { ... }
    Err(e) => return Err(e),
    Ok(x) => x,
}

// ✅ After (v3) — either propagate, or inspect the rendered message
let instance = decode(bytes)?;
```

The broad v2 `ommx::LogEncodingError` enum is not restored. For the narrower
recovery case “this otherwise valid Integer request has no exact log encoding,”
downcast the v3 error to [`LogEncodingUnavailable`](crate::LogEncodingUnavailable).
Unknown, fixed, or non-Integer variables and auxiliary-allocation or
substitution failures remain ordinary errors. Exact integer-slack callers can
similarly downcast to
[`ExactIntegerSlackUnavailable`](crate::ExactIntegerSlackUnavailable) before
selecting an explicitly approximate transformation.

**Moved / renamed error types:**

- `ommx::QplibParseError``ommx::qplib::QplibParseError`. The type is
  slimmer (1-based `line_num` + rendered `message`, no variant enum),
  and no longer re-exported at the crate root.

**Key lookups now return `Option<T>`:**

```rust,ignore
// ❌ Before — typed Err when the key was missing
let solution: Solution = sample_set
    .get(id)
    .map_err(|UnknownSampleIDError { .. }| /* handle */)?;

// ✅ After — Option, lifted at the boundary if your caller wants Result
let solution: Solution = sample_set
    .get(id)
    .ok_or_else(|| ommx::error!("unknown sample id {id:?}"))?;
```

**Signal-type recovery is unchanged in syntax** — it just now flows
through `ommx::Error` instead of a bespoke enum:

```rust,ignore
match instance.propagate(&state, atol) {
    Err(e) if e.is::<ommx::InfeasibleDetected>() => { /* handle */ }
    Err(e) => return Err(e),
    Ok(outcome) => { /* ... */ }
}
```

## New Types

### ConstraintType Trait

A type family mapping lifecycle stages to concrete types (HKT defunctionalization):

```rust,ignore
pub trait ConstraintType {
    type ID: Clone + Copy + Ord + Hash + Debug;
    type Created: Evaluate<Output = Self::Evaluated, SampledOutput = Self::Sampled>
        + Clone + Debug + PartialEq;
    type Evaluated: EvaluatedConstraintBehavior<ID = Self::ID>;
    type Sampled: SampledConstraintBehavior<ID = Self::ID, Evaluated = Self::Evaluated>;
}

// Regular constraints
impl ConstraintType for Constraint {
    type ID = ConstraintID;
    // ...
}

// Indicator constraints
impl ConstraintType for IndicatorConstraint {
    type ID = IndicatorConstraintID;
    // ...
}
```

### Behavior Traits

Two traits define common behavior for evaluated and sampled constraints:

```rust,ignore
pub trait EvaluatedConstraintBehavior {
    type ID;
    fn is_feasible(&self) -> bool;
}

pub trait SampledConstraintBehavior {
    type ID;
    type Evaluated;
    fn is_feasible_for(&self, sample_id: SampleID) -> Option<bool>;
    fn get(&self, sample_id: SampleID) -> Option<Self::Evaluated>;
}
```

Neither trait exposes the constraint's ID — it lives on the enclosing
`BTreeMap` key. `is_removed()` is similarly absent from the traits;
use `EvaluatedCollection::is_removed(&id)` or
`SampledCollection::is_removed(&id)` instead.

### ConstraintCollection

Generic collection of active + removed constraints, plus the SoA
context store for the kind. Also implements `Evaluate`:

```rust,ignore
pub struct ConstraintCollection<T: ConstraintType> {
    active: BTreeMap<T::ID, T::Created>,
    removed: BTreeMap<T::ID, (T::Created, RemovedReason)>,
    context: ConstraintContextStore<T::ID>,
}

// Methods (public)
collection.active()                    // &BTreeMap<T::ID, T::Created>
collection.removed()                   // &BTreeMap<T::ID, (T::Created, RemovedReason)>
collection.context()                   // &ConstraintContextStore<T::ID>
// There is no public split-into-maps operation. Mutation and conversion go
// through Instance / ParametricInstance methods so invariants
// (active/removed disjointness, variable-id validity, and context ownership)
// are enforced. Crate-internal transformations use operation-level row
// effects rather than raw map mutation.

// Evaluate trait impl
collection.evaluate(state, atol)           // EvaluatedCollection<T>
collection.evaluate_samples(samples, atol) // SampledCollection<T>
collection.partial_evaluate(state, atol)   // only active constraints
collection.required_ids()                  // VariableIDSet
```

Removed constraints are just `Created` constraints paired with a `RemovedReason`. The `Removed` stage type no longer exists.

### EvaluatedCollection / SampledCollection

Generic wrappers for evaluation results, used in `Solution` and `SampleSet`. Each carries the same `ConstraintContextStore<T::ID>` as the source `ConstraintCollection<T>` so per-id context is available at every stage:

```rust,ignore
pub struct EvaluatedCollection<T: ConstraintType> {
    constraints: BTreeMap<T::ID, T::Evaluated>,
    removed_reasons: BTreeMap<T::ID, RemovedReason>,
    context: ConstraintContextStore<T::ID>,
}

pub struct SampledCollection<T: ConstraintType> {
    constraints: BTreeMap<T::ID, T::Sampled>,
    removed_reasons: BTreeMap<T::ID, RemovedReason>,
    context: ConstraintContextStore<T::ID>,
}

// Both Deref to BTreeMap<T::ID, T::Evaluated/Sampled> for backward-compatible access
// and provide feasibility / removal / context accessors:
collection.is_feasible()               // all constraints feasible
collection.is_feasible_relaxed()       // all non-removed constraints feasible
collection.is_removed(&id)             // check if a constraint was removed
collection.removed_reasons()           // &BTreeMap<T::ID, RemovedReason>
collection.context()                   // &ConstraintContextStore<T::ID>
```

### Modeling labels and constraint context

Per-collection Struct-of-Arrays stores replace the inline fields that
used to live on every `Constraint` / `DecisionVariable` /
`NamedFunction`, and on legacy `v1::Parameter` rows. Decision variables,
parameters, and named functions carry `ModelingLabel`; constraints carry
`ConstraintContext`, which contains a `ModelingLabel` plus constraint-only
transformation provenance. Four families:

```rust,ignore
pub struct ConstraintContextStore<ID> { /* label + provenance */ }
pub struct VariableLabelStore       { /* same, no provenance */ }
pub struct ParameterLabelStore      { /* same, no provenance */ }
pub struct NamedFunctionLabelStore  { /* same, no provenance */ }
```

Per-host accessors on `Instance` and `ParametricInstance` give read
access to every store. Label/context writes go through owner-checked setters
so labels/provenance cannot be attached to IDs that the host does not
own:

```rust,ignore
instance.constraint_context()              // &ConstraintContextStore<ConstraintID>
instance.set_constraint_context(id, context)?
instance.indicator_constraint_context()    // &ConstraintContextStore<IndicatorConstraintID>
instance.set_indicator_constraint_context(id, context)?
instance.one_hot_constraint_context()
instance.set_one_hot_constraint_context(id, context)?
instance.sos1_constraint_context()
instance.set_sos1_constraint_context(id, context)?
instance.variable_labels()                // &VariableLabelStore
instance.set_variable_label(id, label)?
parametric.parameters()                   // &ParameterTable
parametric.parameters().labels()          // &ParameterLabelStore
instance.named_function_table()           // &NamedFunctionTable<NamedFunction>
instance.named_function_labels()          // &NamedFunctionLabelStore
instance.set_named_function_label(id, label)?
```

`Solution` and `SampleSet` expose the variable / named-function stores
the same way (`solution.variable_labels()`,
`solution.evaluated_named_function_table()`,
`solution.named_function_labels()`, same on `SampleSet`), but
constraint context is reached through the evaluated / sampled
collection getter then `.context()` on the collection — there are no
flattened `solution.constraint_context()` shortcuts at the host level:

```rust,ignore
solution.evaluated_constraints().context()              // &ConstraintContextStore<ConstraintID>
solution.evaluated_indicator_constraints().context()    // … <IndicatorConstraintID>
solution.evaluated_one_hot_constraints().context()
solution.evaluated_sos1_constraints().context()

sample_set.constraints().context()                      // &ConstraintContextStore<ConstraintID>
sample_set.indicator_constraints().context()
// etc.
```

Store API:

```rust,ignore
impl<ID> ConstraintContextStore<ID> {
    // Per-field borrowing reads. EMPTY_* sentinels cover the absent case
    // so the underlying Option<…> storage doesn't leak through.
    pub fn name(&self, id: ID)        -> Option<&str>;
    pub fn subscripts(&self, id: ID)  -> &[i64];
    pub fn parameters(&self, id: ID)  -> &FnvHashMap<String, String>;
    pub fn description(&self, id: ID) -> Option<&str>;
    pub fn provenance(&self, id: ID)  -> &[Provenance];

    // One-shot owned reconstruction matching the I/O struct.
    pub fn collect_for(&self, id: ID) -> ConstraintContext;

    // Setters (write-through to the SoA store).
    pub fn set_name(&mut self, id: ID, name: impl Into<String>);
    pub fn set_subscripts(&mut self, id: ID, s: impl Into<Vec<i64>>);
    pub fn push_subscript(&mut self, id: ID, value: i64);
    pub fn set_parameter(&mut self, id: ID, key: impl Into<String>, value: impl Into<String>);
    pub fn set_parameters(&mut self, id: ID, params: FnvHashMap<String, String>);
    pub fn set_description(&mut self, id: ID, desc: impl Into<String>);
    pub fn push_provenance(&mut self, id: ID, p: Provenance);
    pub fn set_provenance(&mut self, id: ID, p: Vec<Provenance>);

    // Bulk owned exchange with the I/O struct.
    pub fn insert(&mut self, id: ID, context: ConstraintContext);
    pub fn remove(&mut self, id: ID) -> ConstraintContext;
}
```

`VariableLabelStore`, `ParameterLabelStore`, and
`NamedFunctionLabelStore` mirror the shape above with the provenance fields
omitted (`provenance(id)`, `push_provenance`, `set_provenance`).
`VariableLabelStore` keeps the subscript append helpers (`push_subscript`,
`extend_subscripts`); `NamedFunctionLabelStore` does not — extend a named
function's subscripts via `set_subscripts(id, new_vec)` instead.

### Fixed decision-variable values

Decision-variable IDs and fixed values no longer live on
[`DecisionVariable`](crate::DecisionVariable). The variable struct is now the
row data of the host's decision-variable table and contains only its intrinsic
definition (`kind`, `bound`). The [`VariableID`](crate::VariableID) is owned by
the enclosing table key. Created-stage hosts
([`Instance`](crate::Instance) and
[`ParametricInstance`](crate::ParametricInstance)) store rows, modeling labels,
and fixed values together in
[`DecisionVariableTable`](crate::DecisionVariableTable). The table validates
that labels and fixed values target existing
decision-variable IDs and that fixed values satisfy the row kind/bound.
`DecisionVariableTable` is parameterized by the same shared lifecycle stages as
constraints:
[`EvaluatedDecisionVariableTable`](crate::EvaluatedDecisionVariableTable) and
[`SampledDecisionVariableTable`](crate::SampledDecisionVariableTable) are the
evaluated and sampled stage aliases, sharing the same row-ID and label-owner
invariants while omitting the created-stage fixed-value column.

The row still owns the `kind`/`bound` invariant: `DecisionVariable::new` and
bound mutation normalize `bound` through `kind.consistent_bound(bound, atol)`.
This preserves the main-branch guarantee that a safely constructed
`DecisionVariable` never stores an unnormalized bound for its kind.

Construction signatures changed accordingly:

```rust,ignore
// ❌ Before
let x = DecisionVariable::new(id, kind, bound, Some(value), atol)?;
let y = DecisionVariable::new(id, kind, bound, None, atol)?;
let z = DecisionVariable::binary(id);
let continuous = DecisionVariable::continuous(id);
let semi_integer = DecisionVariable::semi_integer(id);
let semi_continuous = DecisionVariable::semi_continuous(id);
dv.substitute(value, atol)?;
let fixed = dv.substituted_value();

let evaluated = EvaluatedDecisionVariable::new(dv, value, atol)?;
let sampled = SampledDecisionVariable::new(dv, samples, atol)?;

// ✅ After
let y = DecisionVariable::new(kind, bound, atol)?;
let z = DecisionVariable::binary();
let continuous = DecisionVariable::continuous();
let semi_integer = DecisionVariable::semi_integer();
let semi_continuous = DecisionVariable::semi_continuous();

let instance = Instance::builder()
    .decision_variables(BTreeMap::from([(id, y.clone())]))
    .fixed_decision_variable_values(BTreeMap::from([(id, value)]))
    .build()?;

let fixed = instance.fixed_decision_variable_value(id);
let all_fixed = instance.fixed_decision_variable_values();

let evaluated = EvaluatedDecisionVariable::new(id, y, value)?;
let sampled = SampledDecisionVariable::new(id, y, samples)?;
```

When constructing a created-stage table directly, use
`DecisionVariableTable::with_fixed_values(entries, labels, fixed_values, atol)`.
If no variables are fixed, pass an empty `fixed_values` map; this is the same
table schema with an empty fixed-value column, not a separate construction mode.

`Instance::partial_evaluate` writes new fixed values into the created
decision-variable table.
State entries for keys in `decision_variable_dependency` are treated as
consistency assertions: they are accepted only when the dependency RHS is fully
determined by other fixed/state values and matches within tolerance. For
example, with `y <- 2 * x`, `partial_evaluate({x: 2, y: 4})` is accepted and
normalizes `y` into `fixed_decision_variable_values`, while
`partial_evaluate({y: 4})` is rejected because it would require solving the
dependency backwards.
Legacy v1 protobuf `substituted_value` fields are still accepted on parse, but
the parser drains them into the same table before constructing the domain
model. The host builder rejects states where a fixed variable is also
solver-used or dependent, and `ParametricInstance` additionally rejects
decision-variable / parameter ID collisions; these host-level invariants cannot
be checked by an individual `DecisionVariable` or by the table alone.

`EvaluatedDecisionVariable::new(id, ...)` and
`SampledDecisionVariable::new(id, ...)` accept an ID so non-finite value errors
can still report the table key. The evaluated/sampled row data itself does not
store the ID; `Solution` and `SampleSet` own it through
[`EvaluatedDecisionVariableTable`](crate::EvaluatedDecisionVariableTable) and
[`SampledDecisionVariableTable`](crate::SampledDecisionVariableTable),
respectively.

### Decision-variable analysis helpers

The old all-in-one analysis helper has been split into owner-side queries on
[`Instance`](crate::Instance). If downstream code previously called
`analyze_decision_variables()` only to find a variable's state role, query the
roles directly:

```rust,ignore
let roles = instance.decision_variable_roles();
let role = instance.decision_variable_role(id);

let fixed_values = instance.fixed_decision_variables();
let dependent = instance.dependent_decision_variable_ids();
let irrelevant = instance.irrelevant_decision_variable_ids();
```

Use [`Instance::decision_variable_usage`](crate::Instance::decision_variable_usage)
when you still need the reverse usage index for variables that appear in
objective / constraint / named-function bodies. The direct role helpers are the
preferred replacement for migration code that only needs fixed / dependent /
irrelevant classification.

### Named-function table ownership

Named-function IDs and labels no longer live on
[`NamedFunction`](crate::NamedFunction),
[`EvaluatedNamedFunction`](crate::EvaluatedNamedFunction), or
[`SampledNamedFunction`](crate::SampledNamedFunction). The row structs carry
only intrinsic data:

- `NamedFunction`: the [`Function`]crate::Function
- `EvaluatedNamedFunction`: the evaluated value and used decision-variable IDs
- `SampledNamedFunction`: sampled values and used decision-variable IDs

The [`NamedFunctionID`](crate::NamedFunctionID) and modeling labels are owned by
[`NamedFunctionTable`](crate::NamedFunctionTable). `Instance` and
`ParametricInstance` store `NamedFunctionTable<NamedFunction>`, `Solution`
stores `NamedFunctionTable<EvaluatedNamedFunction>`, and `SampleSet` stores
`NamedFunctionTable<SampledNamedFunction>`. The table keeps row payloads and
[`NamedFunctionLabelStore`](crate::NamedFunctionLabelStore) together so labels
cannot be attached to unknown named-function IDs at validated construction
boundaries.

Host accessors expose shared table views only. They intentionally do not expose
mutable row views because changing a named-function body after host validation
could introduce undefined variable IDs. Add named functions through
[`Instance::new_named_function`](crate::Instance::new_named_function) or the
validated builders; `new_named_function` returns the allocated
[`NamedFunctionID`](crate::NamedFunctionID), not a mutable row reference.

Construction changes mirror constraints and decision variables:

```rust,ignore
// Before
let nf = NamedFunction {
    id,
    function,
};
let evaluated = named_function.evaluate(&state, atol)?;
let evaluated_id = evaluated.id();

// After
let nf = NamedFunction { function };
let instance = Instance::builder()
    .named_functions(BTreeMap::from([(id, nf.clone())]))
    .build()?;

let evaluated = nf.evaluate(&state, atol)?;
let solution = Solution::builder()
    .evaluated_named_functions(BTreeMap::from([(id, evaluated)]))
    .build()?;
```

The deprecated `Solution::new(...)` constructor was removed because it was a
safe API that skipped host-level validation. Construct solutions through
`Solution::builder().build()?`; reserve `build_unchecked` for code paths where
the enclosing owner has already guaranteed all `Solution` invariants.

Legacy `ommx.v1` protobuf messages still carry an inline `id` field. Rust parse
drains that field into the owning map key, and Rust serialization fills it from
the map key; the domain row remains ID-less on both sides of the conversion.

### Parameter table ownership

`ParametricInstance` parameters now follow the same owner-boundary rule, but
with one important difference: parameters intentionally do **not** get a
separate `ParameterID` type. Parameter references share the
[`VariableID`](crate::VariableID) namespace with decision variables because a
[`Function`](crate::Function) only carries variable IDs; only the enclosing
[`ParametricInstance`](crate::ParametricInstance) can decide whether an ID is a
decision variable or a parameter.

The Rust domain model therefore stores parameters as a
[`ParameterTable`](crate::ParameterTable):

- [`ParameterTable`]crate::ParameterTable owns the parameter ID set and the
  [`ParameterLabelStore`]crate::ParameterLabelStore.
- The table-level invariant is that label IDs are a subset of parameter IDs.
- [`ParametricInstance`]crate::ParametricInstance owns the host-level
  invariants: parameter IDs and decision-variable IDs are disjoint, expression
  bodies reference IDs from their union, and structural decision-variable
  positions such as indicator / one-hot / SOS1 members never use parameter IDs.
- Parameter values are not table data. They are supplied later through
  [`ParametricInstance::with_parameters`]crate::ParametricInstance::with_parameters.

This removes the former `BTreeMap<VariableID, v1::Parameter>` duplication where
the map key and `v1::Parameter.id` both claimed to own the same ID. Legacy
`ommx.v1.Parameter` protobuf rows are still parsed and written at the
serialization boundary, but their inline IDs and labels are drained into /
filled from the `ParameterTable`.

```rust,ignore
// Before
let parameters = BTreeMap::from([(
    id,
    v1::Parameter {
        id: id.into_inner(),
        name: Some("p".to_string()),
        ..Default::default()
    },
)]);
let pi = ParametricInstance::builder()
    .parameters(parameters)
    .build()?;

// After
let mut labels = ParameterLabelStore::default();
labels.set_name(id, "p");
let parameters = ParameterTable::new(BTreeSet::from([id]), labels)?;
let pi = ParametricInstance::builder()
    .parameters(parameters)
    .build()?;
```

### ConstraintContext

Owned struct used as the I/O type for constraint context (insertion via
`add_constraint(c, context)`, owned reads via `store.collect_for(id)`,
modeling-chain staging on the Python `Constraint` snapshot wrapper).
The modeling label is nested so provenance remains separate:

```rust,ignore
pub struct ConstraintContext {
    pub label: ModelingLabel,
    /// Chain of transformations that produced this constraint.
    /// Empty for directly-authored constraints; populated when e.g. an
    /// IndicatorConstraint is promoted to a regular Constraint.
    pub provenance: Vec<Provenance>,
}
```

### Fallible coefficient and function arithmetic

[`Coefficient`](crate::Coefficient) now rejects zero, infinity, and NaN at the
type boundary. Arithmetic on coefficients and functions can therefore return
[`CoefficientError`](crate::CoefficientError), and cancellation can produce the
absence of a coefficient. The `coeff!` macro is still convenient for known
non-zero literals, but it panics for `0.0`; use `Coefficient::try_from(...)` for
runtime values.

In tests and examples, build expressions by propagating the arithmetic result
and use the explicit zero constructors when the intended value is zero:

```rust,ignore
use ommx::{coeff, linear, Coefficient, Function, Linear};

let term = (coeff!(2.0) * linear!(1))?;
let shifted = (term + coeff!(3.0))?;
let function = Function::from(shifted);

let zero_function = Function::default();
let zero_linear = Linear::default();

let runtime = Coefficient::try_from(weight)?;
let weighted = (runtime * linear!(2))?;

let maybe_zero_constant = Function::try_from(0.0)?;
```

Avoid `coeff!(0.0)` and avoid assuming that `a + b`, `a - b`, or scalar
multiplication is infallible. Put `?` (or an explicit `match`) at the test
helper boundary that already returns `ommx::Result` /
`Result<_, CoefficientError>`.

### Dataset loader return shapes

Dataset loaders such as [`ommx::dataset::miplib2017::load`](crate::dataset::miplib2017::load)
and [`ommx::dataset::qplib::load`](crate::dataset::qplib::load) now return the
loaded [`Instance`](crate::Instance) directly:

```rust,ignore
let instance = ommx::dataset::miplib2017::load("air05")?;
```

If v2 code destructured a tuple to get dataset annotations, keep the instance
load separate from the metadata lookup:

```rust,ignore
let instance = ommx::dataset::miplib2017::load("air05")?;
let annotations = ommx::dataset::miplib2017::instance_annotations();
let air05_annotations = annotations.get("air05");
```

## Migration Checklist

- [ ] Remove `constraint.id` reads — look up the ID via the enclosing `BTreeMap<ConstraintID, _>` key instead
- [ ] Update `Constraint::equal_to_zero(id, function)` / `Constraint::less_than_or_equal_to_zero(id, function)` → drop the ID argument (`Constraint::equal_to_zero(function)`), insert with the key
- [ ] Update `constraint.function``constraint.function()` or `constraint.stage.function`
- [ ] Update `constraint.name` reads — context is no longer on the constraint struct. Query the host's SoA store: `instance.constraint_context().name(id)` (and `subscripts`, `parameters`, `description`, `provenance`); use `collect_for(id) -> ConstraintContext` for an owned snapshot.
- [ ] Update `evaluated.evaluated_value()``evaluated.stage.evaluated_value` (and other getset methods)
- [ ] Update `RemovedConstraint` construction → `(Constraint, RemovedReason)` tuple
- [ ] Update `removed.constraint.xxx``removed.0.xxx` (tuple access)
- [ ] Update `removed_reason` / `removed_reason_parameters``RemovedReason { reason, parameters }`
- [ ] Update `evaluated.removed_reason()``collection.removed_reasons().get(&id)`
- [ ] Update struct literals to use `stage: CreatedData { ... }` / `EvaluatedData { ... }` / etc.
- [ ] Update `self.constraints` / `self.removed_constraints``self.constraint_collection.active()` / `.removed()`
- [ ] Remove any `getset` usage for constraint types
- [ ] Update any `InstanceError` / `MpsParseError` / `QplibParseError` / `StateValidationError` / `LogEncodingError` / `UnknownSampleIDError` matches → propagate ordinary failures, or downcast to `LogEncodingUnavailable` / another signal type only for an intentional recovery path
- [ ] Replace `Result<T, UnknownSampleIDError>` key-lookup methods with `Option<T>` on the call site
- [ ] Replace `DecisionVariable::new(id, kind, bound, ..., atol)` with `DecisionVariable::new(kind, bound, atol)`, and insert it under the desired `VariableID` key in the host table
- [ ] Replace `DecisionVariable::binary(id)` / `integer(id)` / `continuous(id)` / `semi_integer(id)` / `semi_continuous(id)` / etc. with the no-argument row factories, and keep the ID on the enclosing map key
- [ ] Replace `DecisionVariable::substituted_value()` and `DecisionVariable::substitute(...)` with host-owned fixed values: `InstanceBuilder::fixed_decision_variable_values(...)`, `Instance::fixed_decision_variable_value(id)`, or `Instance::fixed_decision_variable_values()`
- [ ] Replace `analyze_decision_variables()` role queries with `Instance::decision_variable_roles()`, `Instance::decision_variable_role(id)`, `Instance::fixed_decision_variables()`, `Instance::dependent_decision_variable_ids()`, or `Instance::irrelevant_decision_variable_ids()`; keep `Instance::decision_variable_usage()` only when you need reverse expression usage
- [ ] Update `EvaluatedDecisionVariable::new(...)` and `SampledDecisionVariable::new(...)`: drop the `atol` argument, pass the `VariableID` separately for diagnostics, and keep using the enclosing `Solution` / `SampleSet` map key as the source of truth
- [ ] Remove `NamedFunction.id`, `EvaluatedNamedFunction::id()`, and `SampledNamedFunction::id()` reads in Rust. Use the enclosing `NamedFunctionTable<_>` key instead
- [ ] Construct `NamedFunction { function }` rows and insert them under the desired `NamedFunctionID` key; keep row maps and labels together with `NamedFunctionTable`
- [ ] Update `Instance::new_named_function(...)` callers to use the returned `NamedFunctionID`; it no longer returns `&mut NamedFunction`
- [ ] Replace `BTreeMap<VariableID, v1::Parameter>` on Rust `ParametricInstance` builders / constructors with `ParameterTable`; keep parameter IDs as `VariableID` keys, not a separate `ParameterID`
- [ ] Move parameter `name` / `subscripts` / `parameters` / `description` access to `parametric.parameters().labels()`, and keep concrete parameter values in `ParametricInstance::with_parameters(...)`
- [ ] Add `?` or explicit error handling around coefficient / function arithmetic, and use `Function::default()` / `Linear::default()` / `Function::try_from(0.0)?` instead of `coeff!(0.0)` for zero values
- [ ] Update `ommx::dataset::miplib2017::load(...)` / `qplib::load(...)` callers to expect an `Instance` return value directly; call `instance_annotations()` separately if the migration still needs dataset metadata

---

# Rust SDK v3 Artifact API Migration Guide

This section covers the Artifact / Local Registry API changes for users
moving from `ommx` v2 to v3. The v3 Local Registry is SQLite-backed
(IndexStore + filesystem CAS BlobStore) rather than an on-disk OCI
Image Layout per `image:tag`.

## Overview

Artifact construction was a single generic `Builder<Base: ImageBuilder>`
that switched between `.ommx` archive output and a legacy on-disk
"OCI Image Layout" local registry depending on the `Base` type. v3
collapses that split: every commit goes through `ArtifactDraft`
and lands in the SQLite Local Registry. A `.ommx` file is just an
exchange-format export of a registry-resident artifact, produced by
`LocalArtifact::save(path)`.

| v2 | v3 |
|---|---|
| `Builder<OciDirBuilder>` (local registry) | `ArtifactDraft` |
| `Builder<OciArchiveBuilder>` (`.ommx` file) | `ArtifactDraft::new(...).commit()?.save(path)?` |

The local-registry path now writes an OCI Image Manifest (per OCI 1.1
spec, with `artifactType`) into a SQLite-backed registry instead of an
on-disk OCI Image Layout directory. Existing legacy
`<root>/<image>/<tag>/` directories are identity-preserved on import
via `ommx import-legacy` or the `import_legacy_local_registry*` SDK
functions — pulled bytes (manifest digest and JSON) round-trip verbatim.

## Breaking Changes

### 1. Local Registry draft

```rust,ignore
// ❌ Before
use ommx::artifact::Builder;
let mut builder = Builder::for_github("Jij-Inc", "demo", "experiment", "v1")?;
builder.add_instance(instance, annotations)?;
let artifact = builder.build()?;

// ✅ After
use ommx::artifact::ArtifactDraft;
let mut draft = ArtifactDraft::for_github("Jij-Inc", "demo", "experiment", "v1")?;
draft.add_instance(instance)?;
let artifact = draft.commit()?;
```

`Builder<OciDirBuilder>::{new, for_github}` are removed. Use
`ArtifactDraft::{new, for_github}` instead. Output lands in the
v3 SQLite registry rather than the legacy `<root>/<image>/<tag>/`
OCI Image Layout directory.

### 2. Archive output goes through ArtifactDraft

```rust,ignore
// ❌ Before
use ommx::artifact::Builder;
let mut builder = Builder::new_archive(path, image_name)?;
builder.add_instance(instance, ann)?;
let artifact = builder.build()?;

// ✅ After
use ommx::artifact::ArtifactDraft;
let mut draft = ArtifactDraft::new(image_name)?;
draft.add_instance(instance)?;
let artifact = draft.commit()?;
artifact.save(&path)?;
```

`ArchiveArtifactBuilder` is gone. The same `ArtifactDraft`
publishes into the SQLite Local Registry, and `LocalArtifact::save`
exports a `.ommx` file. Constructors:

- `ArtifactDraft::new(image_name)?` — caller-supplied ref name.
- `ArtifactDraft::new_anonymous()?` — constructs
  `<registry-id8>.ommx.local/anonymous:<local-timestamp>-<nonce>`
  against the default registry's `registry_id` (a random UUID
  generated once per `LocalRegistry` and persisted in SQLite
  metadata). The local-time `YYYYMMDDTHHMMSS` prefix lets you read
  the creation time at a glance, and the 12-hex (48-bit) random nonce
  keeps concurrent / scripted anonymous commits collision-free
  regardless of the host's clock resolution. The `.local` mDNS TLD
  prevents an accidental push from leaking to a real remote registry.
  `ommx prune-anonymous` bulk-cleans every registry-id
  prefix's anonymous refs.
- `ArtifactDraft::temp()` — random `ttl.sh/<uuid>:1h` name;
  insecure, tests only.
- `ArtifactDraft::for_github(org, repo, name, tag)` — GHCR
  helper.

`commit()` returns `LocalArtifact`. The `add_*` signatures are
`add_layer_bytes` / `add_instance` / `add_solution` /
`add_parametric_instance` / `add_sample_set`.

### 3. Archive input imports into the Local Registry

The old read path opened an OCI archive directly and exposed its layers:

```rust,ignore
// ❌ Before
let artifact = Artifact::from_oci_archive(path)?;
let layers = artifact.get_layers()?;
```

In v3, import the `.ommx` exchange-format archive into the SQLite Local
Registry first, then read its descriptors and blobs through the returned
artifact handle:

```rust,ignore
use ommx::artifact::{media_types, LocalArtifactDyn};

let artifact = LocalArtifactDyn::import_archive(path)?;
let layers = artifact.layers()?;

let instance_layer = layers
    .iter()
    .find(|layer| media_types::is_instance_payload_media_type(layer.media_type()))
    .expect("archive should contain an instance layer");
let instance = artifact.get_instance_layer(instance_layer)?;
```

`layers()` returns registry-backed descriptors. Use the typed helpers such as
`get_instance_layer(...)`, `get_solution_layer(...)`, and `get_blob(...)`
instead of treating the archive as an in-place filesystem object. If you only
need to inspect a `.ommx` archive without importing its blobs, use the CLI
`ommx inspect <archive>` flow; the Rust SDK path above is for code that wants
to read layer payloads.

## Migration Checklist

- [ ] Replace `ommx::artifact::Builder` (both `OciDirBuilder` and
      `OciArchiveBuilder` variants) with `ArtifactDraft`.
- [ ] Replace `Builder::new_archive(path, name)` + `.build()` with
      `ArtifactDraft::new(name)?.commit()?.save(&path)?`.
- [ ] Replace `Builder::new_archive_unnamed(path)` with
      `ArtifactDraft::new_anonymous()?.commit()?.save(&path)?`.
- [ ] Replace `Artifact::from_oci_archive(path)?.get_layers()?` with
      `LocalArtifactDyn::import_archive(path)?`, then call `layers()` and typed
      layer readers such as `get_instance_layer(...)`.

- [ ] Replace `Builder::for_github` with `ArtifactDraft::for_github`.
- [ ] Replace `temp_archive()` with `ArtifactDraft::temp()?.commit()?.save(&path)?`.
- [ ] Replace `ocipkg::ImageName` with `ommx::artifact::ImageRef`. The
      type is a newtype around `oci_spec::distribution::Reference`,
      so the full distribution-reference grammar applies. It accepts
      `host[:port]/name:tag`, `host[:port]/name@<digest>`, and the
      combined `tag@<digest>` form on parse, and canonicalises digest
      references to `name@<digest>` on `Display` (tag references keep
      `:`). The accessor shape is `registry()` (the joined
      `host[:port]` form, same as
      `oci_spec::distribution::Reference::registry`) plus `name()` /
      `reference()`. The v2 split accessors `hostname` /
      `port` are **gone**: every internal consumer ended up
      rejoining them at the call site, so the wrapper now exposes
      the joined form directly. Callers that genuinely need just the
      host portion (e.g. a localhost / 127.* heuristic) should parse
      `registry()` inline. Bare-namespace inputs without an
      explicit registry (`library/ubuntu:20.04`, `alpine`) default to
      `docker.io` via the standard Docker reference heuristic — the
      first segment is only treated as a host when it contains `.`
      or `:` or equals `localhost`. The `ommx::ocipkg` re-export is
      removed in v3, so any direct `use ommx::ocipkg::ImageName` call
      site needs to switch.
- [ ] Be aware of the **Docker Hub hostname canonicalisation** when
      sharing user-data caches across SDK versions. SDK v2's `ocipkg`
      defaulted bare image names to the hostname
      `registry-1.docker.io`; v3 normalises to the OCI canonical
      `docker.io` with `library/` prefix added for single-segment
      names. `ImageRef::parse` includes a one-line shim that rewrites
      `registry-1.docker.io/` to `docker.io/` so v2 archive
      annotations and disk-cache layouts collapse onto the same SQLite
      key that `Artifact.load("alpine")` queries. `ocipkg`'s legacy
      digest spelling `name:algorithm:hex` is **not** accepted by
      `oci_spec` and is not back-translated — digest-pinned v2
      annotations had to already use the OCI-standard `name@<digest>`
      form (which is what ocipkg's archive writer emitted in
      practice).
- [ ] Drop calls to `ommx::artifact::get_image_dir` /
      `ommx::artifact::image_dir`. These returned a v2 disk-cache
      path (`<root>/<image_name>/<tag>/`) that no longer corresponds
      to anything in the v3 SQLite Local Registry. The v2 → v3
      migration check that previously read this path moves to
      `ommx::artifact::local_registry::LocalRegistry::legacy_ref_path_in`,
      which is the public compatibility entry point that still computes
      the v2-shaped path for migration checks.
      The `ommx image-dir <name>` CLI subcommand and the Python
      `ommx.get_image_dir` function are removed for the same
      reason — pointing users at a path that is unrelated to v3
      storage was actively misleading.