diff --git a/experiments/r3-facet-carrier/Cargo.lock b/experiments/r3-facet-carrier/Cargo.lock new file mode 100644 index 00000000..034096a5 --- /dev/null +++ b/experiments/r3-facet-carrier/Cargo.lock @@ -0,0 +1,396 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "oa-core" +version = "0.2.0" +dependencies = [ + "same-file", + "serde", + "serde_json", + "tempfile", + "thiserror", + "uuid", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r3-facet-carrier-probe" +version = "0.0.0" +dependencies = [ + "oa-core", + "serde", + "serde_json", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/experiments/r3-facet-carrier/Cargo.toml b/experiments/r3-facet-carrier/Cargo.toml new file mode 100644 index 00000000..278cb88b --- /dev/null +++ b/experiments/r3-facet-carrier/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "r3-facet-carrier-probe" +version = "0.0.0" +edition = "2024" +publish = false + +[workspace] + +[dependencies] +oa-core = { path = "../../crates/oa-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/experiments/r3-facet-carrier/README.md b/experiments/r3-facet-carrier/README.md new file mode 100644 index 00000000..63d31aed --- /dev/null +++ b/experiments/r3-facet-carrier/README.md @@ -0,0 +1,116 @@ +# R3 facet carrier experiment + +Status: isolated design/probe, not R3 implementation or an accepted persistence contract. + +Base: `959b83c78fcaa82b0184aade81a0508b47eb1e41`. Worktree: `D:/CodexBuilds/Capella2-transition-probe`, branch `codex/transition-probe`. The standalone crate depends on the unchanged local `oa-core`. It changes no accepted data4 shape or production code. + +## Finding + +Prefer **B: existing typed own-record carriers plus finite facet selectors** for further review. A carrier holds independently acknowledged values, not a coherent historical model. This removes the main reason to add immutable observation histories/cursors. One narrow extension is justified by a demonstrated overlap: store a separately acknowledged order vector for each ordered collection. + +This is a proposal to investigate further. The probe covers three concrete cases, not every model constructor, mapping direction, source baseline format, or transition command lifecycle. + +| Design | Benefits | Cost or failure demonstrated | +| --- | --- | --- | +| A: `SemanticPath(Vec)` + recursive `SemanticValue` | Each leaf has independent storage; arbitrary nested values can be presented uniformly | Adds a second public model of numbers, enums, records, lists, references, and absence. Every read/write still needs an exhaustive typed adapter and closed path catalog. Wrong path/value combinations become runtime errors. Constructor groups, mapping, collection deltas, and validation remain necessary. Its flexibility supplies no demonstrated benefit for the three cases. | +| B0: one typed carrier per side + reviewed selectors | Reuses `ElementBody`, `RelationBody`, serde validation of shapes, and existing payload types; handles partial scalar review and historical references | **Insufficient for independently acknowledged membership and order.** A new step appended to the carrier can falsely acknowledge its pending placement. | +| B1: B0 + typed `OrderCheckpoint` | Preserves the older reviewed order while independently changing member presence; passes the counterexample | Duplicates only an order's identity vector. Other overlapping projections must be identified before adoption. | +| Immutable observation pairs + facet cursors | Preserves actual whole-record observations and independently selects review epochs | Adds observation IDs/indexes, retention, compaction, duplicate snapshots, and cursor validation. Mixed-time carrier values are legitimate, so the tested scalar cases do not justify this machinery. Order needs only its own typed checkpoint. Rejected for now, not proved universally unnecessary. | + +A is not intrinsically incorrect. The rejection is unnecessary public representation and validation cost for a closed typed model, not a claim that it cannot implement the semantics. + +## Smallest public shape under test + +The exact compiled types are in `src/lib.rs`. They deliberately leave current prose `TransitionField::{Name,Description}`, `FieldDecision::Value(String)`, and `TracedProperties` unchanged. A production extension could add `TraceBaseline.semantic: Option` and `TransitionField::Semantic(Facet)`; old absent semantic baselines remain Unreviewed. + +```rust +enum OwnRecord { + Element { parent: Option, body: ElementBody }, + Relation { body: RelationBody }, +} +struct MemberPair { oa: Option, sa: Option } +struct SemanticBaseline { + oa: OwnRecord, + sa: OwnRecord, + reviewed: Vec, + orders: Vec, +} +struct OrderCheckpoint { + collection: OrderedCollection, + oa: Vec, + sa: Vec, +} +enum OrderedCollection { Steps } // finite catalog to extend +enum Facet { + IntegerMinimum, IntegerMaximum, DataUnit, FieldMinimum, + StateGuard, Trigger(MemberPair), Step(MemberPair), StepsOrder, + DataConstructor, +} +enum Decision { TakeSource, KeepTarget, Candidate } +struct Resolution { facet: Facet, decision: Decision } +struct Review { + source: Id, + target: Id, + candidate: Option, + resolutions: Vec, +} +``` + +`Facet` is the closed proof subset, not a proposed complete catalog. A final catalog needs typed enum branches for element/relation scalar fields, parent/endpoints, reference members, order, and constructor scopes. It must not accept string paths. Message annotations need their existing `(path, field)` member identity; binding keys and other non-UUID members likewise need a finite typed key variant. `MemberPair` uses fixed OA/SA coordinates so reverse transition does not rename persistent selectors. At least one side must exist; conflicting pairs must be rejected. Historical pairs permit removal deltas even after a source object and its ordinary trace have been deleted. + +All carrier types derive `PartialEq`; bodies can contain finite floats. Runtime admission must reject duplicate selectors, invalid selector/body combinations, non-finite values and excessive sizes. It must **not** impose live graph existence or cross-facet invariants on historical carriers. A baseline with independently acknowledged minimum=120 and maximum=100 is valid history of those two facets even though that combination was never a valid live range. Unreviewed slots are carrier storage, not evidence of acknowledgement. + +`DataConstructor` is a grouped decision: the probe copies definition and default together. Production drift should compare constructor discriminants separately from descendant values; accepting a switch must refresh the complete new constructor group and retire old descendant selectors. That full catalog/acknowledgement expansion is not implemented by this subset. Constructor scope does not include unrelated unit/prose or silently delete target children. + +A typed candidate is supplied once per target record, not a recursive merged value per leaf. The adapter selects only acknowledged facets. The probe reconstructs the expected final record and rejects a supplied candidate that changes any unselected slot. Identity/alias are absent; the existing body layer/kind must remain consistent with the explicit mapping. Full model validation still decides whether all accepted choices can coexist. + +Preview can carry `source_before`, `source_now`, `target_before`, `target_now`, and `candidate` as `OwnRecord`, plus finite changed-facet rows. The GUI reads typed selected slots from these carriers. Historical `before` carriers must be labeled as per-facet reviewed values, never as a single past project state. Existing prose `FieldChange` can stay unchanged; a sibling semantic review payload avoids replacing its strings with a new value language. + +## Executed cases + +1. **Bounded data and field multiplicity.** Source integer 0..100 m becomes 120..200 cm; target remains 0..100 ft. Keep minimum and unit acknowledges those facets only. The source carrier intentionally becomes 120..100, while maximum still reports drift. Taking maximum produces target 0..200 ft. A second application is unchanged. Taking a field minimum preserves its independently larger target maximum. Replacing a target Record with Integer is rejected atomically while its target-only field remains; applying an Integer-minimum selector across Record/Integer constructors also rejects. +2. **State guard and triggers.** Delete the old OA Event; source then points to a new Event and changes guard. Target has an extra trigger and its own guard. Keep guard, take old-trigger removal and new-trigger addition. The historical `MemberPair` still identifies the removed source reference. Result preserves target-only trigger and guard, unlinks the old mapped target trigger, and leaves the target Event object alive. Reapplication is unchanged. Baseline JSON still roundtrips the deleted UUID. +3. **Ordered steps.** Source reverses A/B while target inserts independent step X. Take-source ordering reports that an explicit final order is needed. Candidate `[B,A]` rejects because X is missing; `[B,X,A]` passes and X's record stays byte-for-byte equal. Duplicate resolutions and a hidden parent edit reject. The same test proves the B0 counterexample: starting from `[A,B]`, independently acknowledging appended C changes the carrier to `[A,B,C]`, falsely hiding unreviewed order. B1 keeps order checkpoint `[A,B]` and reports the pending change. + +The merge functions are intentionally small OA→SA experiments. They use real `prepare_command(Command::Batch(...))` validation before returning either the project or updated baseline, so the rejected constructor leaves both inputs intact. The baseline is outside the real Project; undo/redo, revision, ETag, fingerprint and on-disk atomic baseline integration are not claimed proved. + +## Source and JSON parity implication + +The tests prove serde JSON roundtrip for the new carrier and for each live candidate. Each live candidate also roundtrips through the existing `language::format` / `language::parse` typed Arcadia 4 implementation. No public arbitrary value tree or escaped JSON blob is persisted by this proposal. + +**They do not prove persisted R3 baseline source parity.** The source language currently resolves live body reference fields through `@alias` and rejects a quoted UUID there (`language.rs`, `Ast::resolve`). A negative test transplants the deleted-Event historical body into a live declaration: its formatted source is rejected. This demonstrates the need for an explicit historical context; inventing a live stub or alias would alter semantics. + +The smallest integration route is to reuse the existing typed body serde decoder/constructor catalog inside a new baseline-carrier source context. In that context, references should use a dedicated literal such as `uuid "..."`, resolve directly to `Id`, and bypass live-reference existence checks. Context is essential: a live `@alias` must not become valid after its object disappears, and historical carrier fields must not be pruned by ordinary deletion. The same historical visitor must explicitly remap UUIDs during real identity remapping, including member pairs and order vectors. A generic field-name-only decoder cannot distinguish these meanings. + +The new wrapper/cursor-free types still require source constructor registration, historical token ownership/comment behavior, OpenAPI declarations, resource budgets, and an explicit schema/source migration. Accepted data4 stays unchanged in this experiment. Whether all current typed decoder logic can be reused without duplication needs a targeted follow-up inspection; this report does not assume that work is already solved. + +## Open questions before adoption + +- Exhaustive scalar/reference/constructor coverage and overlap audit. The proof's `DataConstructor` comparison is a group-copy shortcut; production needs separate discriminant drift and complete descendant acknowledgement. Other composite fields may need similar finite split storage, not a new arbitrary history mechanism by default. +- Per-member edits to annotated records, bindings and repeated occurrence contributions. Stable identity is not always a single UUID. Ownership and incident-relation membership plus artifact facets need explicit typed storage outside the own body. +- Automatic ordering when anchors are genuinely unambiguous. The probe conservatively requires an explicit order; it does not implement the contract's allowed automatic insertions. +- New/changed mappings, many-to-one conflict agreement, reverse direction, deleted mapped targets, historic remapping, missing/ambiguous reference counterparts, and validation of client-supplied masks and member pairs. +- Coherent constructor transitions with target-only dependents: one blocking example passed, but successful transitions and every nested constructor group still need coverage. +- Canonical processing of membership before order and constructor groups before descendants, independent of request array order. The bounded probe consumes resolutions in request order. +- Drift caused by shared referenced records, child membership rollups, bounds on historical membership, migration from old prose-only baselines, and production atomic baseline/history/source persistence. + +## Reproduce and evidence + +Run from the worktree in PowerShell. The standalone lockfile is included; the vendor configuration is generated locally and points read-only to the pre-existing vendor directory. The target belongs only to this experiment. + +```powershell +cargo test --manifest-path experiments/r3-facet-carrier/Cargo.toml --locked --offline --config target/r3-probe/cargo.toml --target-dir D:/CodexBuilds/Capella2-transition-probe-target -- --nocapture +``` + +Result: **3 passed, 0 failed**. The final captured command additionally pipes output through `Tee-Object -FilePath target/r3-probe/tests.log` and exits with `$LASTEXITCODE`. + +`target/r3-probe/cargo.toml`: + +```toml +[source.crates-io] +replace-with = "vendored-sources" +[source.vendored-sources] +directory = "D:/CodexBuilds/Capella2-01a06e2e/vendor" +``` + +Evidence is under `target/r3-probe/`: `tests.log`, and generated `evidence/*-baseline.json` / `*-candidate.arcadia` for mixed range, deleted trigger and target-only step. Generated IDs differ between runs; the asserted behavior does not depend on them. No native UI was used, no package installed, and no other checkout or build target was changed. diff --git a/experiments/r3-facet-carrier/src/lib.rs b/experiments/r3-facet-carrier/src/lib.rs new file mode 100644 index 00000000..8af163bc --- /dev/null +++ b/experiments/r3-facet-carrier/src/lib.rs @@ -0,0 +1,927 @@ +//! Throwaway R3 experiment. Nothing in oa-core depends on this crate. +//! A carrier stores independently reviewed facets; it is NOT a past valid model. +use oa_core::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum OwnRecord { + Element { + parent: Option, + body: ElementBody, + }, + Relation { + body: RelationBody, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemberPair { + pub oa: Option, + pub sa: Option, +} + +/// Deliberately finite proof subset, not the final exhaustive R3 catalog. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum Facet { + IntegerMinimum, + IntegerMaximum, + DataUnit, + FieldMinimum, + StateGuard, + Trigger(MemberPair), + Step(MemberPair), + StepsOrder, + /// Definition AND default form one explicit constructor replacement group. + DataConstructor, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticBaseline { + pub oa: OwnRecord, + pub sa: OwnRecord, + pub reviewed: Vec, + pub orders: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum OrderedCollection { + Steps, +} + +/// Order overlaps membership in the live Vec, so it needs a separate review value. +/// No observation IDs, history versions, or generic value representation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OrderCheckpoint { + pub collection: OrderedCollection, + pub oa: Vec, + pub sa: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Decision { + TakeSource, + KeepTarget, + Candidate, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Resolution { + pub facet: Facet, + pub decision: Decision, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Review { + pub source: Id, + pub target: Id, + pub candidate: Option, + pub resolutions: Vec, +} + +pub fn own(p: &Project, id: Id) -> OwnRecord { + if let Some(e) = p.elements.get(&id) { + OwnRecord::Element { + parent: e.parent, + body: e.body.clone(), + } + } else { + OwnRecord::Relation { + body: p.relations[&id].body.clone(), + } + } +} +fn shared(record: &OwnRecord) -> Result<&SharedBody, String> { + match record { + OwnRecord::Element { body, .. } => body.shared().ok_or("Not a shared body".into()), + _ => Err("Not an element".into()), + } +} +fn shared_mut(record: &mut OwnRecord) -> Result<&mut SharedBody, String> { + match record { + OwnRecord::Element { body, .. } => body.shared_mut().ok_or("Not a shared body".into()), + _ => Err("Not an element".into()), + } +} +fn list(record: &OwnRecord, trigger: bool) -> Result<&Vec, String> { + match (record, trigger) { + ( + OwnRecord::Relation { + body: RelationBody::StateTransition { triggers, .. }, + }, + true, + ) => Ok(triggers), + (OwnRecord::Element { body, .. }, false) => { + body.steps().ok_or("Not an ordered sequence".into()) + } + _ => Err("Wrong member selector".into()), + } +} +fn list_mut(record: &mut OwnRecord, trigger: bool) -> Result<&mut Vec, String> { + match (record, trigger) { + ( + OwnRecord::Relation { + body: RelationBody::StateTransition { triggers, .. }, + }, + true, + ) => Ok(triggers), + (OwnRecord::Element { body, .. }, false) => { + body.steps_mut().ok_or("Not an ordered sequence".into()) + } + _ => Err("Wrong member selector".into()), + } +} + +/// Copy same-coordinate facet; mapped reference proposals use copy_member below. +fn copy(dst: &mut OwnRecord, src: &OwnRecord, facet: &Facet, oa: bool) -> Result<(), String> { + match facet { + Facet::IntegerMinimum | Facet::IntegerMaximum => { + let SharedBody::DataType { + definition: DataTypeDefinition::Integer { minimum, maximum }, + .. + } = shared(src)? + else { + return Err("Explicit constructor decision required".into()); + }; + let (minimum, maximum) = (*minimum, *maximum); + let SharedBody::DataType { + definition: + DataTypeDefinition::Integer { + minimum: dst_min, + maximum: dst_max, + }, + .. + } = shared_mut(dst)? + else { + return Err("Explicit constructor decision required".into()); + }; + if *facet == Facet::IntegerMinimum { + *dst_min = minimum; + } else { + *dst_max = maximum; + } + } + Facet::DataUnit => { + let SharedBody::DataType { unit, .. } = shared(src)? else { + return Err("Wrong facet".into()); + }; + let unit = unit.clone(); + let SharedBody::DataType { unit: dst_unit, .. } = shared_mut(dst)? else { + return Err("Wrong facet".into()); + }; + *dst_unit = unit; + } + Facet::FieldMinimum => { + let SharedBody::DataField { minimum, .. } = shared(src)? else { + return Err("Wrong facet".into()); + }; + let minimum = *minimum; + let SharedBody::DataField { + minimum: dst_min, .. + } = shared_mut(dst)? + else { + return Err("Wrong facet".into()); + }; + *dst_min = minimum; + } + Facet::StateGuard => { + let OwnRecord::Relation { + body: RelationBody::StateTransition { guard, .. }, + } = src + else { + return Err("Wrong facet".into()); + }; + let OwnRecord::Relation { + body: + RelationBody::StateTransition { + guard: dst_guard, .. + }, + } = dst + else { + return Err("Wrong facet".into()); + }; + *dst_guard = guard.clone(); + } + Facet::Trigger(pair) | Facet::Step(pair) => { + let id = if oa { pair.oa } else { pair.sa }; + copy_member(dst, src, matches!(facet, Facet::Trigger(_)), id, id)?; + } + Facet::StepsOrder => *list_mut(dst, false)? = list(src, false)?.clone(), + Facet::DataConstructor => { + let SharedBody::DataType { + definition, + default, + .. + } = shared(src)? + else { + return Err("Wrong constructor scope".into()); + }; + let (definition, default) = (definition.clone(), default.clone()); + let SharedBody::DataType { + definition: dst_definition, + default: dst_default, + .. + } = shared_mut(dst)? + else { + return Err("Wrong constructor scope".into()); + }; + *dst_definition = definition; + *dst_default = default; + } + } + Ok(()) +} +fn copy_member( + dst: &mut OwnRecord, + src: &OwnRecord, + trigger: bool, + src_id: Option, + dst_id: Option, +) -> Result<(), String> { + if src_id.is_none() && dst_id.is_none() { + return Err("Empty member identity".into()); + } + let present = + src_id.is_some_and(|id| list(src, trigger).is_ok_and(|items| items.contains(&id))); + let Some(dst_id) = dst_id else { + return if present { + Err("Missing counterpart mapping".into()) + } else { + Ok(()) + }; + }; + let items = list_mut(dst, trigger)?; + if present && !items.contains(&dst_id) { + items.push(dst_id); + } + if !present { + items.retain(|id| *id != dst_id); + } + Ok(()) +} +fn equal(a: &OwnRecord, b: &OwnRecord, facet: &Facet, oa: bool) -> Result { + let mut projected = a.clone(); + copy(&mut projected, b, facet, oa)?; + Ok(projected == *a) +} +pub fn reviewed_changed( + base: &SemanticBaseline, + now: &OwnRecord, + facet: &Facet, + oa: bool, +) -> Result, String> { + if !base.reviewed.contains(facet) { + return Ok(None); + } + if *facet == Facet::StepsOrder { + let checkpoint = base + .orders + .iter() + .find(|o| o.collection == OrderedCollection::Steps) + .ok_or("Missing order checkpoint")?; + return Ok(Some( + list(now, false)? != if oa { &checkpoint.oa } else { &checkpoint.sa }, + )); + } + equal(if oa { &base.oa } else { &base.sa }, now, facet, oa).map(|same| Some(!same)) +} + +/// One validated core candidate, then baseline assignment. No partial commit. +/// OA→SA only in this bounded experiment. +pub fn apply( + p: &Project, + base: &SemanticBaseline, + review: &Review, +) -> Result<(Project, SemanticBaseline), String> { + let source = own(p, review.source); + let current = own(p, review.target); + let mut target = current.clone(); + let mut acknowledged = base.clone(); + let mut seen = Vec::new(); + if review + .resolutions + .iter() + .any(|r| r.facet == Facet::DataConstructor) + && review + .resolutions + .iter() + .any(|r| matches!(r.facet, Facet::IntegerMinimum | Facet::IntegerMaximum)) + { + return Err("Constructor and descendant facets overlap".into()); + } + for resolution in &review.resolutions { + if seen.contains(&resolution.facet) { + return Err("Duplicate facet".into()); + } + seen.push(resolution.facet.clone()); + match resolution.decision { + Decision::KeepTarget => {} + Decision::TakeSource => match &resolution.facet { + Facet::Trigger(pair) | Facet::Step(pair) => copy_member( + &mut target, + &source, + matches!(resolution.facet, Facet::Trigger(_)), + pair.oa, + pair.sa, + )?, + Facet::StepsOrder => { + return Err("This probe requires an explicit final order".into()); + } + facet => copy(&mut target, &source, facet, false)?, + }, + Decision::Candidate => { + let candidate = review.candidate.as_ref().ok_or("Missing candidate")?; + if resolution.facet == Facet::StepsOrder { + let mut expected = list(&target, false)?.clone(); + let mut proposed = list(candidate, false)?.clone(); + expected.sort(); + proposed.sort(); + if expected != proposed || proposed.windows(2).any(|p| p[0] == p[1]) { + return Err( + "Final order must contain every surviving target member exactly once" + .into(), + ); + } + } + copy(&mut target, candidate, &resolution.facet, false)?; + } + } + if resolution.facet == Facet::DataConstructor { + acknowledged.reviewed.retain(|f| { + !matches!( + f, + Facet::IntegerMinimum | Facet::IntegerMaximum | Facet::DataConstructor + ) + }); + } + if resolution.facet == Facet::StepsOrder { + acknowledged + .orders + .retain(|o| o.collection != OrderedCollection::Steps); + acknowledged.orders.push(OrderCheckpoint { + collection: OrderedCollection::Steps, + oa: list(&source, false)?.clone(), + sa: list(&target, false)?.clone(), + }); + } else { + copy(&mut acknowledged.oa, &source, &resolution.facet, true)?; + copy(&mut acknowledged.sa, &target, &resolution.facet, false)?; + } + if !acknowledged.reviewed.contains(&resolution.facet) { + acknowledged.reviewed.push(resolution.facet.clone()); + } + } + // Explicit candidate cannot smuggle changes to any unreviewed facet. + if let Some(candidate) = &review.candidate { + if *candidate != target { + return Err("Candidate changes an unselected facet".into()); + } + } + let command = match target { + OwnRecord::Element { parent, body } => { + let mut e = p.elements[&review.target].clone(); + e.parent = parent; + e.body = body; + Command::EditElement(review.target, e) + } + OwnRecord::Relation { body } => { + let mut r = p.relations[&review.target].clone(); + r.body = body; + Command::EditRelation(review.target, r) + } + }; + let prepared = prepare_command(p, &Command::Batch(vec![command])).map_err(|e| e.to_string())?; + Ok((prepared.project, acknowledged)) +} + +#[cfg(test)] +mod tests { + use super::*; + fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str, parent: Option) -> Id { + p.add_element(kind, layer, name, parent).unwrap() + } + fn set(p: &mut Project, id: Id, body: SharedBody) { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body; + } + fn valid(p: &Project) { + let errors: Vec<_> = validation::validate(p) + .into_iter() + .filter(|i| i.severity == validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); + } + fn baseline(p: &Project, oa: Id, sa: Id, reviewed: Vec) -> SemanticBaseline { + let orders = if reviewed.contains(&Facet::StepsOrder) { + vec![OrderCheckpoint { + collection: OrderedCollection::Steps, + oa: list(&own(p, oa), false).unwrap().clone(), + sa: list(&own(p, sa), false).unwrap().clone(), + }] + } else { + vec![] + }; + SemanticBaseline { + oa: own(p, oa), + sa: own(p, sa), + reviewed, + orders, + } + } + fn resolution(facet: Facet, decision: Decision) -> Resolution { + Resolution { facet, decision } + } + fn roundtrip(p: &Project, base: &SemanticBaseline) { + let json = serde_json::to_string(base).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + *base + ); + assert_eq!(language::parse(&language::format(p)).unwrap(), *p); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(p).unwrap()).unwrap(), + *p + ); + } + fn evidence(name: &str, p: &Project, base: &SemanticBaseline) { + let folder = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/r3-probe/evidence"); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write( + folder.join(format!("{name}-baseline.json")), + serde_json::to_string_pretty(base).unwrap(), + ) + .unwrap(); + std::fs::write( + folder.join(format!("{name}-candidate.arcadia")), + language::format(p), + ) + .unwrap(); + } + fn integer(minimum: i64, maximum: i64, unit: &str) -> SharedBody { + SharedBody::DataType { + definition: DataTypeDefinition::Integer { + minimum: Some(minimum), + maximum: Some(maximum), + }, + default: None, + unit: unit.into(), + } + } + + #[test] + fn bounded_data_partial_keep_take_and_constructor_atomicity() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::DataType, Layer::Oa, "OA bounded", None); + let sa = add(&mut p, Kind::DataType, Layer::Sa, "SA bounded", None); + set(&mut p, oa, integer(0, 100, "m")); + set(&mut p, sa, integer(0, 100, "ft")); + let base = baseline( + &p, + oa, + sa, + vec![ + Facet::IntegerMinimum, + Facet::IntegerMaximum, + Facet::DataUnit, + ], + ); + set(&mut p, oa, integer(120, 200, "cm")); + let review = Review { + source: oa, + target: sa, + candidate: None, + resolutions: vec![ + resolution(Facet::IntegerMinimum, Decision::KeepTarget), + resolution(Facet::DataUnit, Decision::KeepTarget), + ], + }; + let (kept, mixed) = apply(&p, &base, &review).unwrap(); + assert_eq!(kept, p); + // OA baseline now min=120,max=100. This is intentional: fields were reviewed independently. + assert!(matches!( + shared(&mixed.oa).unwrap(), + SharedBody::DataType { + definition: DataTypeDefinition::Integer { + minimum: Some(120), + maximum: Some(100) + }, + .. + } + )); + assert_eq!( + reviewed_changed(&mixed, &own(&p, oa), &Facet::IntegerMinimum, true).unwrap(), + Some(false) + ); + assert_eq!( + reviewed_changed(&mixed, &own(&p, oa), &Facet::IntegerMaximum, true).unwrap(), + Some(true) + ); + let take = Review { + source: oa, + target: sa, + candidate: None, + resolutions: vec![resolution(Facet::IntegerMaximum, Decision::TakeSource)], + }; + let (p, merged) = apply(&p, &mixed, &take).unwrap(); + assert!( + matches!(shared(&own(&p, sa)).unwrap(), SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(200) }, unit, .. } if unit == "ft") + ); + let (again, same) = apply(&p, &merged, &take).unwrap(); + assert_eq!(again, p); + assert_eq!(same, merged); + roundtrip(&p, &mixed); + evidence("mixed-range", &p, &mixed); + + let mut fields = p; + let owner_oa = add(&mut fields, Kind::DataType, Layer::Oa, "OA record", None); + let owner_sa = add(&mut fields, Kind::DataType, Layer::Sa, "SA record", None); + for id in [owner_oa, owner_sa] { + set( + &mut fields, + id, + SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype: None }, + default: None, + unit: String::new(), + }, + ); + } + let field_oa = add( + &mut fields, + Kind::DataField, + Layer::Oa, + "OA field", + Some(owner_oa), + ); + let field_sa = add( + &mut fields, + Kind::DataField, + Layer::Sa, + "SA field", + Some(owner_sa), + ); + for (id, max) in [(field_oa, 5), (field_sa, 9)] { + set( + &mut fields, + id, + SharedBody::DataField { + type_ref: TypeRef::Named(if id == field_oa { oa } else { sa }), + minimum: 0, + maximum: Some(max), + unit: String::new(), + direction: ParameterDirection::Unspecified, + storage: FieldStorage::Value, + default: None, + }, + ); + } + let field_base = baseline(&fields, field_oa, field_sa, vec![Facet::FieldMinimum]); + if let Some(SharedBody::DataField { minimum, .. }) = + fields.elements.get_mut(&field_oa).unwrap().shared_mut() + { + *minimum = 2; + } + let (fields, field_base) = apply( + &fields, + &field_base, + &Review { + source: field_oa, + target: field_sa, + candidate: None, + resolutions: vec![resolution(Facet::FieldMinimum, Decision::TakeSource)], + }, + ) + .unwrap(); + assert!(matches!( + fields.elements[&field_sa].shared(), + Some(SharedBody::DataField { + minimum: 2, + maximum: Some(9), + .. + }) + )); + roundtrip(&fields, &field_base); + + let mut switched = fields.clone(); + let constructor_base = baseline(&switched, owner_oa, owner_sa, vec![]); + // Remove only the source's child; the target-only field remains real independent work. + switched = prepare_command(&switched, &Command::Delete(field_oa)) + .unwrap() + .project; + set(&mut switched, owner_oa, integer(0, 10, "")); + valid(&switched); + let before = switched.clone(); + let before_base = constructor_base.clone(); + let result = apply( + &switched, + &constructor_base, + &Review { + source: owner_oa, + target: owner_sa, + candidate: None, + resolutions: vec![resolution(Facet::DataConstructor, Decision::TakeSource)], + }, + ); + assert!( + result.is_err(), + "target Record child blocks primitive constructor switch" + ); + assert_eq!(switched, before); + assert_eq!(constructor_base, before_base); + let forbidden = apply( + &switched, + &constructor_base, + &Review { + source: owner_oa, + target: owner_sa, + candidate: None, + resolutions: vec![resolution(Facet::IntegerMinimum, Decision::TakeSource)], + }, + ); + assert!(forbidden.unwrap_err().contains("constructor")); + println!( + "bounded data: mixed historical min/max permitted; unselected drift retained; target max/unit preserved; constructor candidate rejected atomically" + ); + } + + fn state_side(p: &mut Project, layer: Layer, prefix: &str) -> (Id, Id, Id) { + let owner = add( + p, + if layer == Layer::Oa { + Kind::Entity + } else { + Kind::System + }, + layer, + &format!("{prefix} owner"), + None, + ); + let machine = add( + p, + Kind::StateMachine, + layer, + &format!("{prefix} machine"), + Some(owner), + ); + let region = add( + p, + Kind::Region, + layer, + &format!("{prefix} region"), + Some(machine), + ); + let a = add(p, Kind::State, layer, &format!("{prefix} A"), Some(region)); + let b = add(p, Kind::State, layer, &format!("{prefix} B"), Some(region)); + let old = add(p, Kind::Event, layer, &format!("{prefix} old event"), None); + let new = add(p, Kind::Event, layer, &format!("{prefix} new event"), None); + let relation = p.relate( + RelationKind::StateTransition, + &format!("{prefix} transition"), + a, + b, + ); + if let RelationBody::StateTransition { + triggers, guard, .. + } = &mut p.relations.get_mut(&relation).unwrap().body + { + *triggers = vec![old]; + *guard = "ready".into(); + } + (relation, old, new) + } + #[test] + fn state_guard_and_trigger_delta_retain_deleted_historical_identity() { + let mut p = Project::empty(); + let (oa, old_oa, new_oa) = state_side(&mut p, Layer::Oa, "OA"); + let (sa, old_sa, new_sa) = state_side(&mut p, Layer::Sa, "SA"); + let extra = add(&mut p, Kind::Event, Layer::Sa, "Target only", None); + let old = MemberPair { + oa: Some(old_oa), + sa: Some(old_sa), + }; + let new = MemberPair { + oa: Some(new_oa), + sa: Some(new_sa), + }; + let base = baseline( + &p, + oa, + sa, + vec![Facet::StateGuard, Facet::Trigger(old), Facet::Trigger(new)], + ); + p = prepare_command(&p, &Command::Delete(old_oa)) + .unwrap() + .project; + if let RelationBody::StateTransition { + triggers, guard, .. + } = &mut p.relations.get_mut(&oa).unwrap().body + { + *triggers = vec![new_oa]; + *guard = "armed".into(); + } + if let RelationBody::StateTransition { + triggers, guard, .. + } = &mut p.relations.get_mut(&sa).unwrap().body + { + triggers.push(extra); + *guard = "local inhibit clear".into(); + } + valid(&p); + assert!(!p.elements.contains_key(&old_oa)); + assert!(list(&base.oa, true).unwrap().contains(&old_oa)); + // Current source uses live @alias semantics. A historical body needs a + // distinct source context, not transplantation into a live declaration. + let mut historical_as_live = p.clone(); + if let OwnRecord::Relation { body } = &base.oa { + historical_as_live.relations.get_mut(&oa).unwrap().body = body.clone(); + } + let rejected = language::parse(&language::format(&historical_as_live)); + assert!( + rejected.is_err(), + "deleted historical UUID is not a live alias" + ); + let review = Review { + source: oa, + target: sa, + candidate: None, + resolutions: vec![ + resolution(Facet::StateGuard, Decision::KeepTarget), + resolution(Facet::Trigger(old), Decision::TakeSource), + resolution(Facet::Trigger(new), Decision::TakeSource), + ], + }; + let (p, acknowledged) = apply(&p, &base, &review).unwrap(); + assert_eq!(list(&own(&p, sa), true).unwrap(), &vec![extra, new_sa]); + assert!(p.elements.contains_key(&old_sa)); + assert!( + matches!(&p.relations[&sa].body, RelationBody::StateTransition { guard, .. } if guard == "local inhibit clear") + ); + roundtrip(&p, &base); + roundtrip(&p, &acknowledged); + evidence("deleted-trigger", &p, &base); + let (again, same) = apply(&p, &acknowledged, &review).unwrap(); + assert_eq!(again, p); + assert_eq!(same, acknowledged); + println!( + "state: old source Event deleted; historical member pair still removes its target link, preserves target object and extra trigger, keeps guard" + ); + } + + fn sequence(p: &mut Project, layer: Layer, prefix: &str) -> (Id, Id, Id) { + let sequence = add( + p, + if layer == Layer::Oa { + Kind::Process + } else { + Kind::FunctionalChain + }, + layer, + prefix, + None, + ); + let a = add( + p, + Kind::Occurrence, + layer, + &format!("{prefix} A"), + Some(sequence), + ); + let b = add( + p, + Kind::Occurrence, + layer, + &format!("{prefix} B"), + Some(sequence), + ); + for id in [a, b] { + set( + p, + id, + SharedBody::Occurrence { + body: OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + precondition: String::new(), + postcondition: String::new(), + }, + ); + } + *p.elements.get_mut(&sequence).unwrap().steps_mut().unwrap() = vec![a, b]; + (sequence, a, b) + } + #[test] + fn ordered_steps_preserve_target_only_members_and_reject_hidden_edits() { + let mut p = Project::empty(); + let (oa, oa_a, oa_b) = sequence(&mut p, Layer::Oa, "OA flow"); + let (sa, sa_a, sa_b) = sequence(&mut p, Layer::Sa, "SA flow"); + let base = baseline(&p, oa, sa, vec![Facet::StepsOrder]); + // Counterexample: a bare carrier loses pending placement review after + // independently acknowledging append membership. The order checkpoint + // keeps the exact older order and therefore retains the drift. + let oa_c = Id::new_v4(); + let sa_c = Id::new_v4(); + let mut source_with_append = own(&p, oa); + list_mut(&mut source_with_append, false).unwrap().push(oa_c); + let mut membership_only = base.clone(); + copy( + &mut membership_only.oa, + &source_with_append, + &Facet::Step(MemberPair { + oa: Some(oa_c), + sa: Some(sa_c), + }), + true, + ) + .unwrap(); + assert_eq!( + membership_only.oa, source_with_append, + "bare carrier would falsely report no order drift" + ); + assert_eq!( + reviewed_changed( + &membership_only, + &source_with_append, + &Facet::StepsOrder, + true + ) + .unwrap(), + Some(true) + ); + let extra = add( + &mut p, + Kind::Occurrence, + Layer::Sa, + "Target only step", + Some(sa), + ); + set( + &mut p, + extra, + SharedBody::Occurrence { + body: OccurrenceBody::Control { + kind: ControlKind::Merge, + }, + precondition: String::new(), + postcondition: String::new(), + }, + ); + *p.elements.get_mut(&sa).unwrap().steps_mut().unwrap() = vec![sa_a, extra, sa_b]; + *p.elements.get_mut(&oa).unwrap().steps_mut().unwrap() = vec![oa_b, oa_a]; + valid(&p); + let take = Review { + source: oa, + target: sa, + candidate: None, + resolutions: vec![resolution(Facet::StepsOrder, Decision::TakeSource)], + }; + assert!( + apply(&p, &base, &take) + .unwrap_err() + .contains("explicit final order") + ); + let mut candidate = own(&p, sa); + *list_mut(&mut candidate, false).unwrap() = vec![sa_b, sa_a]; + let mut review = Review { + candidate: Some(candidate), + resolutions: vec![resolution(Facet::StepsOrder, Decision::Candidate)], + ..take + }; + assert!( + apply(&p, &base, &review) + .unwrap_err() + .contains("every surviving") + ); + *list_mut(review.candidate.as_mut().unwrap(), false).unwrap() = vec![sa_b, extra, sa_a]; + let target_only = p.elements[&extra].clone(); + let (p, acknowledged) = apply(&p, &base, &review).unwrap(); + assert_eq!(list(&own(&p, sa), false).unwrap(), &vec![sa_b, extra, sa_a]); + assert_eq!(p.elements[&extra], target_only); + roundtrip(&p, &acknowledged); + evidence("target-only-step", &p, &acknowledged); + let mut duplicate = review.clone(); + duplicate.resolutions.push(duplicate.resolutions[0].clone()); + assert!( + apply(&p, &acknowledged, &duplicate) + .unwrap_err() + .contains("Duplicate") + ); + let mut hidden = review; + if let Some(OwnRecord::Element { parent, .. }) = &mut hidden.candidate { + *parent = Some(extra); + } + assert!( + apply(&p, &acknowledged, &hidden) + .unwrap_err() + .contains("unselected") + ); + println!( + "steps: ambiguous source reorder demands explicit candidate; missing target-only member and unselected parent edits reject" + ); + } +}