Compare commits

...
Author SHA1 Message Date
sorokin.oleg.edu b2ab2b1feb Prove R3 self-tagged facet reviews against carrier baseline
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
2026-09-06 04:19:13 +03:00
sorokin.oleg.edu 8ac9f464ea Probe R3 typed facet carriers and independent order review 2026-09-06 03:58:48 +03:00
8 changed files with 3549 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
# C: finite typed facet reviews — executable comparison
Status: isolated proof, pending root freeze. Production remains at `959b83c78fcaa82b0184aade81a0508b47eb1e41`; B1 evidence is commit `8ac9f464eaf0ec813ee3ecf903404891ae2a3841`. No R6/schema/source contract changed.
## Result and proposed shape
**11 tests pass: the original 3 B1 tests plus 8 C tests.** C stores reviewed facts directly, without an own-record carrier, reviewed mask, order shadow, observation version or recursive value tree. `OwnRecord` is reused only for transient source/target candidates and existing fixture helpers. The exact types/adapter are in `src/typed_reviews.rs`; assertions are in `src/typed_reviews_tests.rs`.
```rust
enum Observed<T> { NotApplicable, Value(T) }
struct Reviewed<T> { oa: Observed<T>, sa: Observed<T> }
struct KeyPair<K> { oa: Option<K>, sa: Option<K> }
struct ValueKey { path: Vec<Id>, field: Id }
struct Baseline { facets: Vec<FacetReview> }
```
`FacetReview` is self-tagged: Data family leaves carry `Reviewed<Option<i64>>`, `Reviewed<Option<f64>>`, `Reviewed<Option<DataValue>>`, etc.; the tag identifies the facet. Other proof leaves are field minimum, state guard, mapped trigger/step membership, step order, atomic keyed annotations and atomic keyed bindings. Existing `MessageValue`, `LifelineBinding`, `DataValue` and `DataTypeDefinition` are reused. `T` is a concrete closed type in each variant, not an arbitrary recursively nested value.
An absent entry means Unreviewed. `Value(None)` means an applicable optional slot has no value (for example an unbounded Real minimum). `NotApplicable` means a selected constructor branch does not exist on that side. A mixed review keeping an SA Integer while acknowledging an OA Real records Real-minimum OA=`Value(None)`, SA=`NotApplicable`; Integer-maximum records the reverse applicability. A both-side-inapplicable leaf is rejected.
Recommendation remains **C for the full contract**, because facts are explicit and independent. This proof does **not** show fewer lines of implementation: see the measured cost below. B1 remains viable at restricted granularity; C removes its storage-overlap invariants rather than eliminating exhaustive adapter work.
## What the executable proof establishes
| Case | Observed assertions |
| --- | --- |
| Bounds/unit and field minimum | Keep acknowledges only selected facts; unselected maximum still drifts. Take maximum retains target unit/minimum. Field minimum retains target maximum. Repeat application is unchanged. |
| Guard/triggers after deletion | Deleted source UUID remains in a typed historical key. Removal unlinks the mapped trigger but leaves its target object; extra target trigger and local guard survive. |
| Ordered steps | Accepting membership leaves the order fact byte-for-byte unchanged. Explicit final order must contain all surviving target members exactly once. Target-only step object survives unchanged. |
| Qualified annotation rows | Two paths with the same terminal latitude UUID remain separate. Updating/removing the first row does not acknowledge or overwrite the second row's expression. Removal acknowledges the whole selected row. |
| Binding rows | Key is the referenced lifeline; changing `local` updates that row through frozen mapping. Other binding remains unchanged. Missing local mapping rejects. |
| Mixed constructor group | Keep can acknowledge Real/Integer as intentionally different constructors, including correct applicable/not-applicable leaves. Take changes definition+default while preserving unit. A later explicit target switch can reconsider a previous Keep. Retained Record child blocks an incompatible switch atomically. |
| Prepared induced changes | A real inherited-field removal causes core `prepare_command` to prune annotations. C compares the actual prepared project with the reviewed expected project and rejects these unreviewed changes. Project and baseline inputs remain unchanged. |
| Adversarial admission | Exact duplicates, keys colliding on either side, mismatched frozen key mapping, changed pairing against old baseline, wrong-family/both-side-NA Keep, unchanged-constructor acknowledgement, invalid historical row/key combinations, empty key pairs and nonfinite values reject. Collision permutations both reject. Returned baseline is revalidated. |
Constructor grouping copies the definition and default in this subset; accepting a switch retires old definition facts and records the union of both resulting constructor branches. Unit stays independent. An unchanged constructor cannot be used as a shortcut to acknowledge changed bounds/defaults. Constructor and descendant decisions in one request reject as overlapping. Membership operations precede order regardless of request order.
Pair remapping is deliberately conservative: a new KeyPair colliding with an old baseline pair rejects with an explicit review-invalidation requirement. This probe does not implement the subsequent mapping-reset workflow or agreement-based many-to-one merges. It cannot silently accumulate a baseline that fails on its next application.
## Measured implementation cost
`measure-code.ps1` counts nonblank, non-comment rustfmt lines. B1 is read from its committed version, not a moving file. These are code-size measurements, not a normalized complexity or speed benchmark.
| Portion | B1 | C | Difference |
| --- | ---: | ---: | ---: |
| Public types/requests | 69 | 118 | +49 |
| Adapter/admission/application | 256 | 778 | +522 |
| Tests | 512 | 1,035 | +523 |
Both use the same 52 lines of basic own-record/list helpers; C does not call B1's carrier copy/compare/apply logic. C covers 18 selector cases versus B1's 9, including both keyed row kinds and all DataType constructor branches. C also adds admission and prepared-project checks absent from B1. Therefore attributing the entire difference to representation would be misleading. C's repeated capture/tag/write matches are real maintenance cost; one finite catalog should supply selector/type metadata during implementation, without introducing a general metamodel framework.
## Full coverage and minimal granularity recommendation
| Family | Facets required by the current model |
| --- | --- |
| Headers | Name, description, element parent. No incidental identity/alias/layer edits. |
| OA/SA primitive bodies | No payload; Process/FunctionalChain step membership and order. |
| ExchangeItem / Interface | Nature, mechanism, supertype; interface supertype. Allocations remain real relation records. |
| DataType | Constructor, integer/real bounds, string lengths, enum literal membership, Record supertype, default, unit. |
| DataField | Whole TypeRef, multiplicity bounds, unit, direction, storage, whole optional default vector. No unstable per-index default selection. |
| Port | Contract constructor; input/output item membership; component kind/orientation; legacy direction/interface. Existing typed/Legacy resolution and split semantics remain authoritative. |
| Scenario / Lifeline | Viewpoint, context, pre/postconditions, step membership/order; represented reference. |
| Occurrence envelope | Constructor and separate common pre/postconditions. |
| Exchange occurrence | Exchange, sender, receiver, message kind/Timer expression, reply correlation, annotations keyed by full `(path,field)`. |
| Remaining occurrences | Action/action-lifeline; control kind; sequence reference; scenario reference/bindings; state/state-lifeline; fragment operator/branches; branch guard/ordered occurrences. |
| Event / State | Event constructor and Signal items/Time kind+expression/Change expression; StateKind structural change and entry/exit/do prose. Machine/Region have no own payload. |
| Every relation | Source and target endpoints, plus name/description. |
| Relation payloads | Interface allocation order/protocols; association roles/multiplicities/navigation; exchange items/ports/orientation; capability-extend condition; sequence guard; transition kind/trigger+effect sets and three prose fields; StateAction phase. All other relation kinds are endpoint-only. |
| External membership/artifacts | Direct children; incident relation identity+endpoint role; attached artifact membership and name/description/owner/relation/URI/revision/fragment. |
Atomic annotation and binding rows are the smallest useful contract: key changes are explicit remove/add, while all other rows remain independent. A separate expression-versus-presence checkpoint is unnecessary at this granularity. If such controls are later required, C can add separate typed facts without a second carrier state.
Steps/branch occurrences are ordered. Trigger alternatives, bindings and annotations must not accidentally acquire execution ordering semantics from vector position. Enum literals need string-keyed membership to preserve target-only literals; standalone whole-list `TakeSource` is explicitly rejected by this probe, pending that finite selector. A constructor conversion that would overwrite retained target enum literals also requires an explicit candidate. Field default vectors remain atomic.
Constructor scopes own discriminator and introduced/removed variant fields. Dependent changes outside that scope require explicit resolutions. In particular, changing Occurrence's constructor must not acknowledge its outer pre/postconditions; Call→Timer must expose exchange/reply/value effects; StateKind is semantic even though it changes `Element::kind()`. Prepared induced changes must be reviewed for every representation.
Current Trace endpoints/properties do not accept artifacts. Prefer attached artifact review facts owned by the semantic trace instead of silently widening trace compatibility. Missing artifact versus present artifact with `revision=None` needs distinct typed observations.
## Reuse the existing source decoder, with typed context
Baseline JSON roundtrips, including deleted historical UUIDs and mixed constructor observations. All accepted live candidates roundtrip through current Arcadia 4 source. **C baseline source syntax is not implemented or proved.**
The concrete reuse route is:
1. `language.rs::Parser::value_inner`: recognize a distinct historical `uuid "..."` AST literal. Do not convert it to an ordinary quoted string too early.
2. `Ast::resolve`: accept an explicit slot context supplied by the finite facet catalog. Historical reference slots accept UUID literals; live reference slots keep `@alias` requirements. A new context must be entered only through the semantic-baseline subtree of a Trace declaration.
3. Keep `resolve_declarations` and its existing `serde_json::from_value::<Trace>`/body decoding as the typed authority. Do not duplicate the live body factory/default merge logic for historical facts.
4. `write_fields`/`write_value`: use the same slot metadata to emit historical UUIDs without current alias lookup. Generic `Reviewed<T>{oa,sa}` cannot pass through current field-name-only rules: `oa`/`sa` are currently classified as references even when T is a unit string. Renaming keys alone does not distinguish reference/string/enum types.
5. `edit_positions`/`EditKind` and `source_project.rs::item_key`: retain full UUID token spans, historical identity remapping, and full annotation/binding keys for comment ownership.
6. Extend finite historical validation/admission counts and explicit remap visitors; exclude baseline IDs from live dangling-reference and deletion traversal. Historical carrier/facet validation must not fetch old objects or impose cross-facet live range invariants.
This needs explicit schema/source migration and OpenAPI descriptions before acceptance. No source4 or data4 changes were made here.
## Commands, evidence, and stopping point
From `D:/CodexBuilds/Capella2-transition-probe`:
```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 2>&1 | Tee-Object -FilePath target/r3-probe/c-tests.log; exit $LASTEXITCODE
```
Exit 0: **11 passed, 0 failed**, no compiler warnings. The vendor config is unchanged from the B1 report. `& experiments/r3-facet-carrier/measure-code.ps1` reproduces counts. Logs, `code-counts.json` and generated `evidence/c-*-baseline.json` / `c-*-candidate.arcadia` remain under `target/r3-probe/`.
Still outside this proof: complete facet catalog; reverse direction; multiple target records; historical identity remapping; mapping-reset and agreeing many-to-one resolution; unambiguous automatic ordering; source baseline parser/formatter; OpenAPI/migration; size budgets for every new fact; and production baseline+undo/revision/ETag/disk atomicity. String literal/row keys and historical UUID admission need full adversarial coverage at implementation time. No claim of full R3 implementation or acceptance is made.
Stop here for the root's freeze decision. No native UI or other checkout/target was used.
+396
View File
@@ -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"
+12
View File
@@ -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"
+118
View File
@@ -0,0 +1,118 @@
# R3 facet carrier experiment
Status: isolated design/probe, not R3 implementation or an accepted persistence contract.
Follow-up: [C-REPORT.md](C-REPORT.md) contains the executable self-tagged typed-facet comparison, current recommendation, additional adversarial cases and measured code costs. This document preserves the earlier B0/B1 analysis.
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<String>)` + 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<SemanticBaseline>` and `TransitionField::Semantic(Facet)`; old absent semantic baselines remain Unreviewed.
```rust
enum OwnRecord {
Element { parent: Option<Id>, body: ElementBody },
Relation { body: RelationBody },
}
struct MemberPair { oa: Option<Id>, sa: Option<Id> }
struct SemanticBaseline {
oa: OwnRecord,
sa: OwnRecord,
reviewed: Vec<Facet>,
orders: Vec<OrderCheckpoint>,
}
struct OrderCheckpoint {
collection: OrderedCollection,
oa: Vec<Id>,
sa: Vec<Id>,
}
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<OwnRecord>,
resolutions: Vec<Resolution>,
}
```
`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.
@@ -0,0 +1,28 @@
# Run from the isolated worktree root. Counts rustfmt lines, not complexity.
$r3B = @(git show 8ac9f46:experiments/r3-facet-carrier/src/lib.rs)
$r3C = @(Get-Content -LiteralPath experiments/r3-facet-carrier/src/typed_reviews.rs)
$r3Tests = @(Get-Content -LiteralPath experiments/r3-facet-carrier/src/typed_reviews_tests.rs)
function Count-R3Lines($Lines) {
@($Lines | Where-Object { $_.Trim().Length -gt 0 -and -not $_.Trim().StartsWith('//') }).Count
}
function Find-R3Line($Lines, $Prefix) {
for ($r3Index = 0; $r3Index -lt $Lines.Length; $r3Index++) {
if ($Lines[$r3Index].StartsWith($Prefix)) { return $r3Index }
}
throw "Missing section marker: $Prefix"
}
$r3BOwn = Find-R3Line $r3B 'pub fn own('
$r3BCopy = Find-R3Line $r3B 'fn copy('
$r3BTests = Find-R3Line $r3B '#[cfg(test)]'
$r3CAdapter = Find-R3Line $r3C '// ADAPTER:'
$r3CTestStart = Find-R3Line $r3C '#[cfg(test)]'
[ordered]@{
method = 'Nonblank non-comment rustfmt lines; excludes test module registration'
b_public = Count-R3Lines $r3B[5..($r3BOwn-1)]
shared_record_helpers = Count-R3Lines $r3B[$r3BOwn..($r3BCopy-1)]
b_adapter = Count-R3Lines $r3B[$r3BCopy..($r3BTests-1)]
b_tests = Count-R3Lines $r3B[($r3BTests+1)..($r3B.Length-1)]
c_public = Count-R3Lines $r3C[5..($r3CAdapter-1)]
c_adapter = Count-R3Lines $r3C[$r3CAdapter..($r3CTestStart-1)]
c_tests = Count-R3Lines $r3Tests
} | ConvertTo-Json
+934
View File
@@ -0,0 +1,934 @@
//! 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};
pub mod typed_reviews;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum OwnRecord {
Element {
parent: Option<Id>,
body: ElementBody,
},
Relation {
body: RelationBody,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemberPair {
pub oa: Option<Id>,
pub sa: Option<Id>,
}
/// 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<Facet>,
pub orders: Vec<OrderCheckpoint>,
}
#[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<Id>,
pub sa: Vec<Id>,
}
#[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<OwnRecord>,
pub resolutions: Vec<Resolution>,
}
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<Id>, 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<Id>, 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<Id>,
dst_id: Option<Id>,
) -> 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<bool, String> {
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<Option<bool>, 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::*;
pub(crate) fn add(
p: &mut Project,
kind: Kind,
layer: Layer,
name: &str,
parent: Option<Id>,
) -> Id {
p.add_element(kind, layer, name, parent).unwrap()
}
pub(crate) fn set(p: &mut Project, id: Id, body: SharedBody) {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
}
pub(crate) 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<Facet>) -> 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::<SemanticBaseline>(&json).unwrap(),
*base
);
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
assert_eq!(
serde_json::from_str::<Project>(&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();
}
pub(crate) 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"
);
}
pub(crate) 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"
);
}
pub(crate) 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"
);
}
}
@@ -0,0 +1,909 @@
//! C: closed typed reviewed facts. Whole records are transient candidates only.
use crate::{Decision, OwnRecord, list, list_mut, own, shared, shared_mut};
use oa_core::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
// PUBLIC TYPES: persisted facts have no carrier, review mask or shadow order.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Observed<T> {
NotApplicable,
Value(T),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reviewed<T> {
pub oa: Observed<T>,
pub sa: Observed<T>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyPair<K> {
pub oa: Option<K>,
pub sa: Option<K>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ValueKey {
pub path: Vec<Id>,
pub field: Id,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataConstructor {
Boolean,
Integer,
Real,
String,
Enumeration,
Record,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataFacet {
Constructor,
IntegerMinimum,
IntegerMaximum,
RealMinimum,
RealMaximum,
MinimumLength,
MaximumLength,
Literals,
Supertype,
Default,
Unit,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DataReview {
Constructor(Reviewed<DataConstructor>),
IntegerMinimum(Reviewed<Option<i64>>),
IntegerMaximum(Reviewed<Option<i64>>),
RealMinimum(Reviewed<Option<f64>>),
RealMaximum(Reviewed<Option<f64>>),
MinimumLength(Reviewed<u32>),
MaximumLength(Reviewed<Option<u32>>),
Literals(Reviewed<Vec<String>>),
Supertype(Reviewed<Option<Id>>),
Default(Reviewed<Option<DataValue>>),
Unit(Reviewed<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Selector {
Data(DataFacet),
FieldMinimum,
StateGuard,
Trigger(KeyPair<Id>),
Step(KeyPair<Id>),
StepOrder,
Annotation(KeyPair<ValueKey>),
Binding(KeyPair<Id>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FacetReview {
Data(DataReview),
FieldMinimum(Reviewed<u32>),
StateGuard(Reviewed<String>),
Trigger {
keys: KeyPair<Id>,
values: Reviewed<bool>,
},
Step {
keys: KeyPair<Id>,
values: Reviewed<bool>,
},
StepOrder(Reviewed<Vec<Id>>),
Annotation {
keys: KeyPair<ValueKey>,
values: Reviewed<Option<MessageValue>>,
},
Binding {
keys: KeyPair<Id>,
values: Reviewed<Option<LifelineBinding>>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Baseline {
pub facets: Vec<FacetReview>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Resolution {
pub facet: Selector,
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<OwnRecord>,
pub resolutions: Vec<Resolution>,
pub references: BTreeMap<Id, Id>,
}
// ADAPTER: exhaustive typed capture, comparison, and application for this subset.
fn observed<T>(v: Option<T>) -> Observed<T> {
v.map_or(Observed::NotApplicable, Observed::Value)
}
fn pair<T>(a: &OwnRecord, b: &OwnRecord, f: impl Fn(&OwnRecord) -> Option<T>) -> Reviewed<T> {
Reviewed {
oa: observed(f(a)),
sa: observed(f(b)),
}
}
fn data(r: &OwnRecord) -> Option<(&DataTypeDefinition, &Option<DataValue>, &String)> {
match shared(r).ok()? {
SharedBody::DataType {
definition,
default,
unit,
} => Some((definition, default, unit)),
_ => None,
}
}
fn constructor(d: &DataTypeDefinition) -> DataConstructor {
match d {
DataTypeDefinition::Boolean => DataConstructor::Boolean,
DataTypeDefinition::Integer { .. } => DataConstructor::Integer,
DataTypeDefinition::Real { .. } => DataConstructor::Real,
DataTypeDefinition::String { .. } => DataConstructor::String,
DataTypeDefinition::Enumeration { .. } => DataConstructor::Enumeration,
DataTypeDefinition::Record { .. } => DataConstructor::Record,
}
}
fn annotation(r: &OwnRecord, key: &Option<ValueKey>) -> Option<Option<MessageValue>> {
let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
} = shared(r).ok()?
else {
return None;
};
Some(key.as_ref().and_then(|key| {
values
.iter()
.find(|v| v.path == key.path && v.field == key.field)
.cloned()
}))
}
fn binding(r: &OwnRecord, key: Option<Id>) -> Option<Option<LifelineBinding>> {
let SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { bindings, .. },
..
} = shared(r).ok()?
else {
return None;
};
Some(key.and_then(|key| bindings.iter().find(|v| v.referenced == key).cloned()))
}
impl DataReview {
fn facet(&self) -> DataFacet {
match self {
Self::Constructor(_) => DataFacet::Constructor,
Self::IntegerMinimum(_) => DataFacet::IntegerMinimum,
Self::IntegerMaximum(_) => DataFacet::IntegerMaximum,
Self::RealMinimum(_) => DataFacet::RealMinimum,
Self::RealMaximum(_) => DataFacet::RealMaximum,
Self::MinimumLength(_) => DataFacet::MinimumLength,
Self::MaximumLength(_) => DataFacet::MaximumLength,
Self::Literals(_) => DataFacet::Literals,
Self::Supertype(_) => DataFacet::Supertype,
Self::Default(_) => DataFacet::Default,
Self::Unit(_) => DataFacet::Unit,
}
}
}
impl FacetReview {
pub fn selector(&self) -> Selector {
match self {
Self::Data(v) => Selector::Data(v.facet()),
Self::FieldMinimum(_) => Selector::FieldMinimum,
Self::StateGuard(_) => Selector::StateGuard,
Self::Trigger { keys, .. } => Selector::Trigger(keys.clone()),
Self::Step { keys, .. } => Selector::Step(keys.clone()),
Self::StepOrder(_) => Selector::StepOrder,
Self::Annotation { keys, .. } => Selector::Annotation(keys.clone()),
Self::Binding { keys, .. } => Selector::Binding(keys.clone()),
}
}
}
pub fn capture(a: &OwnRecord, b: &OwnRecord, selector: &Selector) -> FacetReview {
match selector {
Selector::Data(f) => FacetReview::Data(match f {
DataFacet::Constructor => {
DataReview::Constructor(pair(a, b, |r| Some(constructor(data(r)?.0))))
}
DataFacet::IntegerMinimum => DataReview::IntegerMinimum(pair(a, b, |r| {
if let DataTypeDefinition::Integer { minimum, .. } = data(r)?.0 {
Some(*minimum)
} else {
None
}
})),
DataFacet::IntegerMaximum => DataReview::IntegerMaximum(pair(a, b, |r| {
if let DataTypeDefinition::Integer { maximum, .. } = data(r)?.0 {
Some(*maximum)
} else {
None
}
})),
DataFacet::RealMinimum => DataReview::RealMinimum(pair(a, b, |r| {
if let DataTypeDefinition::Real { minimum, .. } = data(r)?.0 {
Some(*minimum)
} else {
None
}
})),
DataFacet::RealMaximum => DataReview::RealMaximum(pair(a, b, |r| {
if let DataTypeDefinition::Real { maximum, .. } = data(r)?.0 {
Some(*maximum)
} else {
None
}
})),
DataFacet::MinimumLength => DataReview::MinimumLength(pair(a, b, |r| {
if let DataTypeDefinition::String { minimum_length, .. } = data(r)?.0 {
Some(*minimum_length)
} else {
None
}
})),
DataFacet::MaximumLength => DataReview::MaximumLength(pair(a, b, |r| {
if let DataTypeDefinition::String { maximum_length, .. } = data(r)?.0 {
Some(*maximum_length)
} else {
None
}
})),
DataFacet::Literals => DataReview::Literals(pair(a, b, |r| {
if let DataTypeDefinition::Enumeration { literals } = data(r)?.0 {
Some(literals.clone())
} else {
None
}
})),
DataFacet::Supertype => DataReview::Supertype(pair(a, b, |r| {
if let DataTypeDefinition::Record { supertype } = data(r)?.0 {
Some(*supertype)
} else {
None
}
})),
DataFacet::Default => DataReview::Default(pair(a, b, |r| Some(data(r)?.1.clone()))),
DataFacet::Unit => DataReview::Unit(pair(a, b, |r| Some(data(r)?.2.clone()))),
}),
Selector::FieldMinimum => FacetReview::FieldMinimum(pair(a, b, |r| {
if let SharedBody::DataField { minimum, .. } = shared(r).ok()? {
Some(*minimum)
} else {
None
}
})),
Selector::StateGuard => FacetReview::StateGuard(pair(a, b, |r| {
if let OwnRecord::Relation {
body: RelationBody::StateTransition { guard, .. },
} = r
{
Some(guard.clone())
} else {
None
}
})),
Selector::Trigger(keys) | Selector::Step(keys) => {
let triggers = matches!(selector, Selector::Trigger(_));
let values = Reviewed {
oa: observed(
list(a, triggers)
.ok()
.map(|v| keys.oa.is_some_and(|id| v.contains(&id))),
),
sa: observed(
list(b, triggers)
.ok()
.map(|v| keys.sa.is_some_and(|id| v.contains(&id))),
),
};
if triggers {
FacetReview::Trigger {
keys: keys.clone(),
values,
}
} else {
FacetReview::Step {
keys: keys.clone(),
values,
}
}
}
Selector::StepOrder => FacetReview::StepOrder(pair(a, b, |r| list(r, false).ok().cloned())),
Selector::Annotation(keys) => FacetReview::Annotation {
keys: keys.clone(),
values: Reviewed {
oa: observed(annotation(a, &keys.oa)),
sa: observed(annotation(b, &keys.sa)),
},
},
Selector::Binding(keys) => FacetReview::Binding {
keys: keys.clone(),
values: Reviewed {
oa: observed(binding(a, keys.oa)),
sa: observed(binding(b, keys.sa)),
},
},
}
}
impl Baseline {
pub fn capture(
p: &Project,
source: Id,
target: Id,
selectors: &[Selector],
) -> Result<Self, String> {
unique(selectors)?;
let result = Self {
facets: selectors
.iter()
.map(|f| capture(&own(p, source), &own(p, target), f))
.collect(),
};
result.validate()?;
Ok(result)
}
pub fn changed(&self, a: &OwnRecord, b: &OwnRecord, selector: &Selector) -> Option<bool> {
self.facets
.iter()
.find(|v| v.selector() == *selector)
.map(|v| *v != capture(a, b, selector))
}
fn upsert(&mut self, review: FacetReview) {
self.facets.retain(|v| v.selector() != review.selector());
self.facets.push(review);
}
fn validate(&self) -> Result<(), String> {
unique(
&self
.facets
.iter()
.map(FacetReview::selector)
.collect::<Vec<_>>(),
)?;
for fact in &self.facets {
if !has_applicable_value(fact) {
return Err("Facet is inapplicable on both sides".into());
}
match fact {
FacetReview::Annotation { keys, values } => {
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty keyed facet".into());
}
for (key, value) in [(&keys.oa, &values.oa), (&keys.sa, &values.sa)] {
if let Observed::Value(Some(value)) = value
&& key.as_ref()
!= Some(&ValueKey {
path: value.path.clone(),
field: value.field,
})
{
return Err("Historical annotation payload disagrees with key".into());
}
}
}
FacetReview::Binding { keys, values } => {
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty keyed facet".into());
}
for (key, value) in [(&keys.oa, &values.oa), (&keys.sa, &values.sa)] {
if let Observed::Value(Some(value)) = value
&& *key != Some(value.referenced)
{
return Err("Historical binding payload disagrees with key".into());
}
}
}
FacetReview::Trigger { keys, .. } | FacetReview::Step { keys, .. }
if keys.oa.is_none() && keys.sa.is_none() =>
{
return Err("Empty keyed facet".into());
}
FacetReview::Data(DataReview::RealMinimum(v) | DataReview::RealMaximum(v)) => {
for v in [&v.oa, &v.sa] {
if let Observed::Value(Some(v)) = v
&& !v.is_finite()
{
return Err("Historical real must be finite".into());
}
}
}
FacetReview::Data(DataReview::Default(v)) => {
for v in [&v.oa, &v.sa] {
if let Observed::Value(Some(DataValue::Real(v))) = v
&& !v.is_finite()
{
return Err("Historical real default must be finite".into());
}
}
}
_ => {}
}
}
Ok(())
}
}
fn has_applicable_value(fact: &FacetReview) -> bool {
fn any<T>(v: &Reviewed<T>) -> bool {
matches!(v.oa, Observed::Value(_)) || matches!(v.sa, Observed::Value(_))
}
match fact {
FacetReview::Data(v) => match v {
DataReview::Constructor(v) => any(v),
DataReview::IntegerMinimum(v) | DataReview::IntegerMaximum(v) => any(v),
DataReview::RealMinimum(v) | DataReview::RealMaximum(v) => any(v),
DataReview::MinimumLength(v) => any(v),
DataReview::MaximumLength(v) => any(v),
DataReview::Literals(v) => any(v),
DataReview::Supertype(v) => any(v),
DataReview::Default(v) => any(v),
DataReview::Unit(v) => any(v),
},
FacetReview::FieldMinimum(v) => any(v),
FacetReview::StateGuard(v) => any(v),
FacetReview::Trigger { values, .. } | FacetReview::Step { values, .. } => any(values),
FacetReview::StepOrder(v) => any(v),
FacetReview::Annotation { values, .. } => any(values),
FacetReview::Binding { values, .. } => any(values),
}
}
fn unique(selectors: &[Selector]) -> Result<(), String> {
fn overlap<K: PartialEq>(a: &KeyPair<K>, b: &KeyPair<K>) -> bool {
a.oa.as_ref().is_some_and(|k| b.oa.as_ref() == Some(k))
|| a.sa.as_ref().is_some_and(|k| b.sa.as_ref() == Some(k))
}
for (index, selector) in selectors.iter().enumerate() {
if selectors[..index].iter().any(|old| {
old == selector
|| match (old, selector) {
(Selector::Trigger(a), Selector::Trigger(b))
| (Selector::Step(a), Selector::Step(b))
| (Selector::Binding(a), Selector::Binding(b)) => overlap(a, b),
(Selector::Annotation(a), Selector::Annotation(b)) => overlap(a, b),
_ => false,
}
}) {
return Err("Duplicate facet or conflicting keyed slot".into());
}
}
Ok(())
}
fn applicable(a: &OwnRecord, b: &OwnRecord, selector: &Selector) -> bool {
[a, b].into_iter().all(|r| match selector {
Selector::Data(_) => data(r).is_some(),
Selector::FieldMinimum => matches!(shared(r), Ok(SharedBody::DataField { .. })),
Selector::StateGuard | Selector::Trigger(_) => matches!(
r,
OwnRecord::Relation {
body: RelationBody::StateTransition { .. }
}
),
Selector::Step(_) | Selector::StepOrder => list(r, false).is_ok(),
Selector::Annotation(_) => annotation(r, &None).is_some(),
Selector::Binding(_) => binding(r, None).is_some(),
})
}
fn check_keys(selector: &Selector, refs: &BTreeMap<Id, Id>) -> Result<(), String> {
fn ids(keys: &KeyPair<Id>, refs: &BTreeMap<Id, Id>) -> Result<(), String> {
match (keys.oa, keys.sa) {
(None, None) => Err("Empty keyed facet".into()),
(Some(a), Some(b)) if refs.get(&a) != Some(&b) => {
Err("Key pair disagrees with frozen reference mapping".into())
}
_ => Ok(()),
}
}
match selector {
Selector::Trigger(k) | Selector::Step(k) | Selector::Binding(k) => ids(k, refs),
Selector::Annotation(k) => match (&k.oa, &k.sa) {
(None, None) => Err("Empty keyed facet".into()),
(Some(a), Some(b)) => {
if a.path.len() != b.path.len() {
return Err("Annotation path mapping differs".into());
}
for (oa, sa) in a
.path
.iter()
.chain([&a.field])
.zip(b.path.iter().chain([&b.field]))
{
ids(
&KeyPair {
oa: Some(*oa),
sa: Some(*sa),
},
refs,
)?;
}
Ok(())
}
_ => Ok(()),
},
_ => Ok(()),
}
}
fn chosen<T: Clone>(v: &Reviewed<T>, source: bool) -> Result<T, String> {
match if source { &v.oa } else { &v.sa } {
Observed::Value(v) => Ok(v.clone()),
Observed::NotApplicable => {
Err("Facet is not applicable; explicit constructor review required".into())
}
}
}
fn definition_mut(r: &mut OwnRecord) -> Result<&mut DataTypeDefinition, String> {
match shared_mut(r)? {
SharedBody::DataType { definition, .. } => Ok(definition),
_ => Err("Wrong data constructor scope".into()),
}
}
fn map_ref(id: Id, refs: &BTreeMap<Id, Id>) -> Result<Id, String> {
refs.get(&id)
.copied()
.ok_or_else(|| format!("Missing reference mapping for {id}"))
}
fn write(
target: &mut OwnRecord,
fact: &FacetReview,
source: bool,
refs: &BTreeMap<Id, Id>,
) -> Result<(), String> {
macro_rules! bound {
($v:expr, $variant:ident, $field:ident) => {{
let value = chosen($v, source)?;
let DataTypeDefinition::$variant { $field, .. } = definition_mut(target)? else {
return Err("Explicit constructor review required".into());
};
*$field = value;
}};
}
match fact {
FacetReview::Data(v) => match v {
DataReview::IntegerMinimum(v) => bound!(v, Integer, minimum),
DataReview::IntegerMaximum(v) => bound!(v, Integer, maximum),
DataReview::RealMinimum(v) => bound!(v, Real, minimum),
DataReview::RealMaximum(v) => bound!(v, Real, maximum),
DataReview::MinimumLength(v) => bound!(v, String, minimum_length),
DataReview::MaximumLength(v) => bound!(v, String, maximum_length),
DataReview::Literals(v) => bound!(v, Enumeration, literals),
DataReview::Supertype(v) => {
let mut value = chosen(v, source)?;
if source {
value = value.map(|id| map_ref(id, refs)).transpose()?;
}
let DataTypeDefinition::Record { supertype } = definition_mut(target)? else {
return Err("Explicit constructor review required".into());
};
*supertype = value;
}
DataReview::Default(v) => {
let SharedBody::DataType { default, .. } = shared_mut(target)? else {
return Err("Wrong facet".into());
};
*default = chosen(v, source)?;
}
DataReview::Unit(v) => {
let SharedBody::DataType { unit, .. } = shared_mut(target)? else {
return Err("Wrong facet".into());
};
*unit = chosen(v, source)?;
}
DataReview::Constructor(_) => return Err("Constructor is a grouped operation".into()),
},
FacetReview::FieldMinimum(v) => {
let SharedBody::DataField { minimum, .. } = shared_mut(target)? else {
return Err("Wrong facet".into());
};
*minimum = chosen(v, source)?;
}
FacetReview::StateGuard(v) => {
let OwnRecord::Relation {
body: RelationBody::StateTransition { guard, .. },
} = target
else {
return Err("Wrong facet".into());
};
*guard = chosen(v, source)?;
}
FacetReview::Trigger { keys, values } | FacetReview::Step { keys, values } => {
let present = chosen(values, source)?;
let Some(id) = keys.sa else {
return if present {
Err("Missing member mapping".into())
} else {
Ok(())
};
};
let items = list_mut(target, matches!(fact, FacetReview::Trigger { .. }))?;
if present && !items.contains(&id) {
items.push(id);
} else if !present {
items.retain(|v| *v != id);
}
}
FacetReview::StepOrder(values) => {
if source {
return Err("Explicit final order required by this probe".into());
}
let value = chosen(values, false)?;
let mut expected = list(target, false)?.clone();
let mut proposed = value.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 member exactly once".into());
}
*list_mut(target, false)? = value;
}
FacetReview::Annotation { keys, values } => {
let mut value = chosen(values, source)?;
let Some(key) = &keys.sa else {
return if value.is_some() {
Err("Missing annotation mapping".into())
} else {
Ok(())
};
};
if let Some(value) = &mut value {
value.path = key.path.clone();
value.field = key.field;
}
let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
} = shared_mut(target)?
else {
return Err("Wrong annotation scope".into());
};
if let Some(index) = values
.iter()
.position(|v| v.path == key.path && v.field == key.field)
{
if let Some(value) = value {
values[index] = value;
} else {
values.remove(index);
}
} else if let Some(value) = value {
values.push(value);
}
}
FacetReview::Binding { keys, values } => {
let mut value = chosen(values, source)?;
let Some(key) = keys.sa else {
return if value.is_some() {
Err("Missing binding mapping".into())
} else {
Ok(())
};
};
if let Some(value) = &mut value {
value.referenced = key;
if source {
value.local = map_ref(value.local, refs)?;
}
}
let SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { bindings, .. },
..
} = shared_mut(target)?
else {
return Err("Wrong binding scope".into());
};
if let Some(index) = bindings.iter().position(|v| v.referenced == key) {
if let Some(value) = value {
bindings[index] = value;
} else {
bindings.remove(index);
}
} else if let Some(value) = value {
bindings.push(value);
}
}
}
Ok(())
}
fn in_constructor(selector: &Selector) -> bool {
matches!(selector, Selector::Data(f) if *f != DataFacet::Unit)
}
fn constructor_group(a: &OwnRecord, b: &OwnRecord) -> Vec<FacetReview> {
use DataFacet::*;
let mut facets = vec![Constructor, Default];
for r in [a, b] {
if let Some((definition, ..)) = data(r) {
for f in match definition {
DataTypeDefinition::Integer { .. } => vec![IntegerMinimum, IntegerMaximum],
DataTypeDefinition::Real { .. } => vec![RealMinimum, RealMaximum],
DataTypeDefinition::String { .. } => vec![MinimumLength, MaximumLength],
DataTypeDefinition::Enumeration { .. } => vec![Literals],
DataTypeDefinition::Record { .. } => vec![Supertype],
DataTypeDefinition::Boolean => vec![],
} {
if !facets.contains(&f) {
facets.push(f);
}
}
}
}
facets
.into_iter()
.map(|f| capture(a, b, &Selector::Data(f)))
.collect()
}
pub fn apply(p: &Project, base: &Baseline, review: &Review) -> Result<(Project, Baseline), String> {
base.validate()?;
unique(
&review
.resolutions
.iter()
.map(|r| r.facet.clone())
.collect::<Vec<_>>(),
)?;
let switch = review
.resolutions
.iter()
.any(|r| r.facet == Selector::Data(DataFacet::Constructor));
if switch
&& review
.resolutions
.iter()
.any(|r| in_constructor(&r.facet) && r.facet != Selector::Data(DataFacet::Constructor))
{
return Err("Overlapping constructor and descendant decisions".into());
}
let source = own(p, review.source);
let current = own(p, review.target);
let mut target = current.clone();
let constructor_selector = Selector::Data(DataFacet::Constructor);
for r in &review.resolutions {
if !applicable(&source, &current, &r.facet) {
return Err("Facet does not belong to this record family".into());
}
check_keys(&r.facet, &review.references)?;
if !has_applicable_value(&capture(&source, &current, &r.facet)) {
return Err("Facet is inapplicable on both sides".into());
}
for previous in &base.facets {
if previous.selector() != r.facet
&& unique(&[previous.selector(), r.facet.clone()]).is_err()
{
return Err(
"Mapping changed; explicitly invalidate the affected previous facet review"
.into(),
);
}
}
if r.facet == Selector::Data(DataFacet::Literals) && r.decision == Decision::TakeSource {
return Err("String-keyed literal deltas are outside this probe; no whole-list TakeSource overwrite".into());
}
}
if switch && base.changed(&source, &current, &constructor_selector) == Some(false) {
let decision = review
.resolutions
.iter()
.find(|r| r.facet == constructor_selector)
.unwrap()
.decision;
let proposed = match decision {
Decision::TakeSource => Some(&source),
Decision::Candidate => review.candidate.as_ref(),
Decision::KeepTarget => None,
};
let actual_target_switch = proposed
.and_then(data)
.zip(data(&current))
.is_some_and(|(new, old)| constructor(new.0) != constructor(old.0));
if !actual_target_switch {
return Err("No constructor change; review descendant facets individually".into());
}
}
if !switch
&& base.changed(&source, &current, &constructor_selector) == Some(true)
&& review.resolutions.iter().any(|r| in_constructor(&r.facet))
{
return Err("Explicit constructor review required".into());
}
let mut resolutions = review.resolutions.iter().collect::<Vec<_>>();
resolutions.sort_by_key(|r| {
if r.facet == constructor_selector {
0
} else if r.facet == Selector::StepOrder {
2
} else {
1
}
});
for r in &resolutions {
if r.decision == Decision::KeepTarget {
continue;
}
let from_source = r.decision == Decision::TakeSource;
let supplied = if from_source {
&source
} else {
review.candidate.as_ref().ok_or("Missing candidate")?
};
if r.facet == constructor_selector {
let (definition, default, _) = data(supplied).ok_or("Wrong constructor group")?;
let mut definition = definition.clone();
if from_source
&& let DataTypeDefinition::Enumeration { literals } = &definition
&& let Some((DataTypeDefinition::Enumeration { literals: old }, ..)) = data(&target)
&& old.iter().any(|v| !literals.contains(v))
{
return Err(
"Constructor candidate must explicitly review target-only enum literals".into(),
);
}
if from_source
&& let DataTypeDefinition::Record {
supertype: Some(id),
} = &mut definition
{
*id = map_ref(*id, &review.references)?;
}
let SharedBody::DataType {
definition: to_definition,
default: to_default,
..
} = shared_mut(&mut target)?
else {
return Err("Wrong constructor group".into());
};
*to_definition = definition;
*to_default = default.clone();
} else {
let fact = capture(&source, supplied, &r.facet);
write(&mut target, &fact, from_source, &review.references)?;
}
}
if review.candidate.as_ref().is_some_and(|v| *v != target) {
return Err("Candidate changes an unselected facet".into());
}
let mut expected = p.clone();
let command = match target {
OwnRecord::Element { parent, body } => {
let e = expected.elements.get_mut(&review.target).unwrap();
e.parent = parent;
e.body = body;
Command::EditElement(review.target, e.clone())
}
OwnRecord::Relation { body } => {
let r = expected.relations.get_mut(&review.target).unwrap();
r.body = body;
Command::EditRelation(review.target, r.clone())
}
};
let prepared = prepare_command(p, &Command::Batch(vec![command])).map_err(|e| e.to_string())?;
if prepared.project != expected {
return Err("Prepared candidate induces unreviewed changes outside selected facets".into());
}
let target = own(&prepared.project, review.target);
let mut acknowledged = base.clone();
for r in resolutions {
if r.facet == constructor_selector {
acknowledged
.facets
.retain(|f| !in_constructor(&f.selector()));
for f in constructor_group(&source, &target) {
acknowledged.upsert(f);
}
} else {
acknowledged.upsert(capture(&source, &target, &r.facet));
}
}
acknowledged.validate()?;
Ok((prepared.project, acknowledged))
}
#[cfg(test)]
#[path = "typed_reviews_tests.rs"]
mod tests;
File diff suppressed because it is too large Load Diff