Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu 4b9932236a Sync agent instructions from main 2026-09-13 11:40:17 +03:00
sorokin.oleg.edu afd81af1f1 Sync agent instructions from main 2026-09-12 13:43:57 +03:00
sorokin.oleg.edu 35d9eab302 Sync agent instructions from main 2026-09-12 13:40:39 +03:00
sorokin.oleg.edu 22e974da35 Sync agent instructions from main 2026-09-12 13:16:22 +03:00
sorokin.oleg.edu bf3f2f69a2 Sync agent instructions from main 2026-09-11 19:52:31 +03:00
sorokin.oleg.edu 75b8815a83 Sync agent instructions from main 2026-09-11 15:55:57 +03:00
sorokin.oleg.edu b2127cbe90 Sync agent instructions from main 2026-09-11 14:34:52 +03:00
sorokin.oleg.edu 1bf8ebca96 Record combined DATA scenario verification and frozen Windows artifact
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
2026-09-06 05:04:32 +03:00
sorokin.oleg.edu dfd2e41259 Merge tested R6 data workflows into direct scenario workspace 2026-09-06 05:04:04 +03:00
sorokin.oleg.edu 05a84b217d Record final scenario save-scroll review and build evidence 2026-09-06 04:59:01 +03:00
sorokin.oleg.edu e7e37d485d Keep observed scenario scrolling current during async saves 2026-09-06 04:58:45 +03:00
sorokin.oleg.edu b35dede75f Browse message value properties by record with bounded pages 2026-09-06 04:56:41 +03:00
sorokin.oleg.edu 5fa9c905c8 Record verified scenario review fixes and fresh binary evidence 2026-09-06 04:54:22 +03:00
sorokin.oleg.edu 7a666076bf Fix scenario scroll cancellation, operand activations and picker cost 2026-09-06 04:53:23 +03:00
sorokin.oleg.edu f5467e50c5 Preserve authored source and reports through desktop migration 2026-09-06 04:43:41 +03:00
sorokin.oleg.edu 74989b6f25 Keep nested data field names visible in compact pickers 2026-09-06 04:40:08 +03:00
sorokin.oleg.edu 524dabb9c3 Record direct scenario checks and pending native acceptance 2026-09-06 04:33:36 +03:00
sorokin.oleg.edu d1d8f0bbed Add direct scenario gestures and atomic ordered message insertion 2026-09-06 04:33:13 +03:00
sorokin.oleg.edu 0edadeebdb Record R6 cross-platform tests and native data workflows 2026-09-06 04:29:29 +03:00
sorokin.oleg.edu 93e3137342 Plan direct scenario drawing and ordered insertion milestone 2026-09-06 04:14:22 +03:00
sorokin.oleg.edu ab661f2d33 Bound scenario value authoring to focused paged fields 2026-09-06 04:08:46 +03:00
sorokin.oleg.edu 15ed1ff3af Verify qualified scenario values through HTTP and record R6 evidence 2026-09-06 04:07:49 +03:00
sorokin.oleg.edu b403039d00 Add typed data contract editing and bounded dictionary views 2026-09-06 03:58:43 +03:00
sorokin.oleg.edu 7426c03f4e Explain R6 data authoring and record verification gaps 2026-09-06 03:58:12 +03:00
38 changed files with 7844 additions and 3861 deletions
+73
View File
@@ -0,0 +1,73 @@
# Agent working agreement
## Working method
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
## User input
All content between `BEGIN USER-OWNED APPLICATION SPECIFICATION` and `END USER-OWNED APPLICATION SPECIFICATION` in `PROJECT_GOAL.md` belongs to the user. Its headings and structure are unrestricted. Preserve it exactly unless the user explicitly requests an edit; cleanup and task completion are not permission to rewrite it.
Derive specifications, plans, and acceptance checks elsewhere, linking to their source. Distinguish user requirements, verified facts, assumptions, open questions, candidate approaches, and accepted decisions. Interpretations must not silently replace the user's intent. When input changes, reconcile affected work. Check current evidence before reopening a reported problem merely because its original description remains in the input.
## Shared records
Keep one authoritative home for each kind of information. Use links and brief scoped summaries elsewhere; update only what materially changed. Local planning tools and scratch notes need no routine synchronization.
| Information | Authoritative home |
| --- | --- |
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
| Published deliverables | Gitea releases, tied to source commits and verification. |
Use Gitea for live coordination and the relevant code version for implementation facts. Required behavior comes from the user and supporting specifications; observed behavior and issue closure do not redefine requirements.
## Coordination and integration
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
## Delegation and reasoning
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
## Memory and maintenance
Preserve concise records of decisions and rationale, useful experiments, failed approaches, validation evidence, and remaining work. Link substantial evidence rather than copying logs into indexes. Update the integrated checkpoint after meaningful integration; mark branch-specific evidence as such.
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
## Delivery
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
+111
View File
@@ -0,0 +1,111 @@
# Project goal
## Objective
Take the existing project to the complete, coherent, usable, and verified outcome defined by the user input below. Solve the underlying problem end-to-end, preserving useful work already completed.
Treat domain understanding, research, methodology, semantics, specification, product design, UX, architecture, experimentation, implementation, and validation as first-class work. Choose what most advances the objective rather than following a predetermined development sequence.
Follow `AGENTS.md` for execution, coordination, record ownership, and delivery. Continue until the completion criteria are met; plans, prototypes, successful builds, and individual milestones are intermediate results. Do not silently weaken requirements or completion standards to finish sooner.
## Completion criteria
Apply these criteria to the scope requested by the user:
- Required capabilities address the intended problem and support realistic user workflows.
- Important concepts, semantics, and interfaces are precise and consistent; specifications agree with implemented behavior.
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
## Final handoff
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
## USER-OWNED APPLICATION SPECIFICATION
The block below accepts all project-specific requirements, context, priorities, and feedback, with any headings or organization the user chooses.
<!-- BEGIN USER-OWNED APPLICATION SPECIFICATION -->
## Application specification
### General description
Create a modern clone of ARCADIA Capella using Rust Iced framework. It should be cross platform (supporting Windows and Linux at least). Use Rust exclusively (wherever possible & practical).
Focus on making the application modern, extensible, intuitive and easy to use. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
Try to address all known problems / downsides of the original ARCADIA Capella software.
Version 1 MVP in the form of working Operational Analysis layer is done.
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
Remaining ARCADIA layers will be added in future releases.
Transitioning of model elements between layers (in both directions) should be easy and intuitive.
Keep in mind that the target audience for the application are system engineers. And you should satisfy their needs and use cases
The application should be a general systems modeling tool, like ARCADIA Capella or SysML v2
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
The original ARCADIA Capella UI is very bloated, clunky, unintuitive and hard to use. In addition, it lacks a number of quality of life features. You should at all cost avoid making the same mistakes as the developers of the original ARCADIA Capella
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
To explore the original ARCADIA Capella UI & semantics you may use web resources / web search / official and community docs. Or open the original ARCADIA Capella installed on this desktop machine for exploration
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
The application should support both forms of editing
The textual representation should be easily editable and understandble for both humans and AI
The textual representation should be easily handled by version control tools such as Git
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
The textual representation editor should have syntax highlighting and linting
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
The desktop application should contain built-in guide / help / tutorial with clear and concise info on modeling concepts, underlying semantics, common modeling scenarios and application features / how to use the app. It should be easy to navigate and explore
@Computer
### Semantic model / metamodel
Requirements:
- Textual notation.
- Graphical notation.
- Constraints and calculations.
- Quantities and units.
- State and behavior modeling.
- Reusable model libraries.
- Strong, standardized specification.
- Strong, standardized API.
#### Textual notation
Requirements:
- Should not have visual noise and clutter.
- Should have means to reduce cognitive load when developing system models.
- Should be easily editable by humans. And AI agents.
- System engineers should be able to easily develop system models (both big and small) using textual notation only.
### UI
The focus should be on reducing system engineer's cognitive load when creating / working with a system model. Regardless of the model size (big or small).
Long, homogeneous lists in the UI that require user to scroll far or search for a desired option for too long - is a bad design.
## Active issues / current guidance
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
---
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
<!-- END USER-OWNED APPLICATION SPECIFICATION -->
+3 -1
View File
@@ -53,6 +53,8 @@ The example opens in an architecture view with work nested inside its performers
**Model explorer** groups roots by engineering purpose and opens one container's direct children. Breadcrumbs navigate ownership; **Allocated work** is listed separately. Search names, kinds or `@aliases` to find elements with ancestor context. Results are paged in groups of 100. **Collapse all** folds the outline; **Locate** clears the filter and reveals the selected element's ancestry.
**Data & interfaces** separates exchange nature from mechanism, supports bounded reusable types and typed defaults, and gives each interface item allocation its own protocol and artifact identity. Operation parameters have provider-side directions; qualified message paths distinguish repeated Record uses. Record/item/interface specialization preserves inherited identities. See the [data contract](docs/language.rst) for exact semantics and compatibility.
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
@@ -70,7 +72,7 @@ In the state view, **Select / move** drags vertices and containers without repar
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
```text
arcadia 3;
arcadia 4;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity respond "Respond to requests" {
+534 -5
View File
@@ -12,6 +12,216 @@ use oa_core::{
use serde_json::{Value, json};
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
#[cfg(test)]
mod direct_scenario_tests {
use super::*;
use crate::behavior::{
self,
scenario::{self, CanvasEvent, Event, MessageStyle, Slot},
};
fn setup() -> (App, scenario::direct_tests::Fixture) {
let f = scenario::direct_tests::fixture();
let mut app = App::with_project(f.project.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.scenario_context().unwrap().scenario, f.owner);
(app, f)
}
fn form(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(behavior::Event::Scenario(event)));
}
fn begin(app: &App, f: &scenario::direct_tests::Fixture) -> CanvasEvent {
CanvasEvent::Begin {
context: app.scenario_context().unwrap(),
slot: Slot {
parent: f.owner,
before: None,
},
sender: f.sender,
receiver: f.receiver,
style: MessageStyle::Asynchronous,
}
}
#[test]
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
let (mut app, f) = setup();
let original = app.document.bundle().clone();
let event = begin(&app, &f);
let context = app.scenario_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
assert_eq!(app.scenario_context(), Some(context));
let _ = app.update(Message::WindowResized(iced::Size::new(
size.width + 20.,
size.height,
)));
assert_ne!(app.scenario_context(), Some(context));
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
let context = app.scenario_context().unwrap();
for (area, offset) in [
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
] {
let before = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
assert_ne!(app.scenario_context(), Some(before));
let current = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
assert_eq!(
app.scenario_context(),
Some(current),
"unchanged redraw notification retains context"
);
}
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(&mut app, Event::Name("Keep my draft".into()));
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context,
scenario::ScrollArea::Page,
iced::Vector::new(0., 200.),
)));
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &original);
let current = app.scenario_context().unwrap();
for dimension in 0..3 {
let mut stale = current;
match dimension {
0 => stale.document = Id::new_v4(),
1 => stale.scenario = Id::new_v4(),
_ => stale.revision += 1,
}
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
stale,
scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(app.scenario_context(), Some(current));
}
assert!(!app.document.can_undo());
}
#[test]
fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() {
let (mut app, f) = setup();
let before = app.document.snapshot();
for dimension in 0..4 {
let mut event = begin(&app, &f);
let CanvasEvent::Begin { context, .. } = &mut event else {
unreachable!()
};
match dimension {
0 => context.document = Id::new_v4(),
1 => context.scenario = Id::new_v4(),
2 => context.revision += 1,
_ => context.epoch += 1,
}
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
let _ = app.update(Message::Field(
"/name".into(),
"Pending scenario name".into(),
));
assert!(app.draft_dirty());
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[test]
fn direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once() {
let (mut app, f) = setup();
let original = app.document.bundle().clone();
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(
&mut app,
Event::Exchange(Choice {
id: f.exchange,
label: "Contract".into(),
}),
);
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
let created = app.selected;
assert_ne!(created, f.owner);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[f.call, f.reply, created]
);
let before_reorder = app.document.bundle().clone();
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
context, f.sender,
)));
assert_eq!(
app.scenario_context(),
Some(context),
"same-owner selection retains gesture context"
);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
context,
occurrence: created,
slot: Slot {
parent: f.owner,
before: Some(f.call),
},
}));
assert_eq!(app.selected, created);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[created, f.call, f.reply]
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before_reorder);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &original);
assert_eq!(app.project(), &f.project);
assert!(!app.document.can_undo());
}
#[test]
fn direct_scenario_source_failure_and_navigation_keep_form_until_success() {
let (mut app, f) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 4000;
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
app.sync();
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(
&mut app,
Event::Exchange(Choice {
id: f.exchange,
label: "Contract".into(),
}),
);
form(&mut app, Event::Name("Long message".repeat(1024)));
let _ = app.update(Message::Page(Page::Catalog));
assert_eq!(app.page, Page::Behavior);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
form(&mut app, Event::Name("Recovered message".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
assert_eq!(app.project().named(app.selected), "Recovered message");
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
}
}
#[cfg(test)]
mod behavior_lifecycle_tests;
mod state_diagram;
@@ -115,6 +325,7 @@ pub enum Message {
BrowseGroup(crate::explorer::Group),
ExplorerPage(usize),
CreateKind(Kind),
Data(crate::data_view::Event),
Add(bool),
AddRelation,
PortAction(crate::port_workflow::Action),
@@ -127,6 +338,9 @@ pub enum Message {
Field(String, String),
FieldText(String, text_editor::Action),
FieldShape(String, Value),
FieldAppend(String, Value),
FieldValuePath(String, crate::data_values::FieldPathKey),
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
FieldVariant(String, Value),
FieldArray(String, Option<usize>, i32),
Apply,
@@ -144,6 +358,7 @@ pub enum Message {
BehaviorOwner(Choice),
BehaviorDiagram(Choice),
StateDiagram(crate::behavior::state_edit::Event),
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
PlaceSelected,
HideSelected,
ToggleCollapse,
@@ -204,6 +419,7 @@ pub struct App {
pub page: Page,
pub guide: crate::guide::State,
pub behavior: crate::behavior::State,
pub data_state: crate::data_view::State,
pub inspector_tab: crate::navigation::InspectorTab,
pub layer: Layer,
pub selected: Id,
@@ -292,6 +508,7 @@ impl App {
page: Page::Diagram,
guide: crate::guide::State::default(),
behavior: Default::default(),
data_state: Default::default(),
inspector_tab: Default::default(),
layer,
selected,
@@ -574,9 +791,10 @@ impl App {
}
fn execute(&mut self, command: Command) -> bool {
let command = crate::port_edit::repair_owner_change(self.project(), command);
let scenario_selection = self.behavior.scenario.created_selection(&command);
match self.document.execute(command) {
Ok(selected) => {
if let Some(id) = selected {
if let Some(id) = scenario_selection.or(selected) {
self.selected = id;
}
self.sync();
@@ -732,6 +950,15 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
// Scrollable geometry can change while async work or an overlay blocks
// commands. Keep the observed translation current; its passive handler
// still validates document, scenario and revision before accepting it.
if matches!(
&message,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..))
) {
return self.handle(message);
}
// Global subscriptions also receive keys consumed by an overlay.
if self.model_menu.is_some() && matches!(message, Message::Escape) {
self.model_menu = None;
@@ -764,6 +991,8 @@ impl App {
Message::Field(..)
| Message::FieldText(..)
| Message::FieldShape(..)
| Message::FieldAppend(..)
| Message::FieldValuePath(..)
| Message::FieldVariant(..)
| Message::FieldArray(..)
| Message::ElementKind(_)
@@ -896,6 +1125,7 @@ impl App {
self.connection = None;
}
if previous_layer != self.layer {
self.data_state = Default::default();
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.explorer_focus = None;
self.explorer_group = None;
@@ -928,8 +1158,115 @@ impl App {
task
}
}
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
let scenario = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
.then_some(crate::behavior::scenario::Context {
document: self.canvas_document,
scenario,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
if let CanvasEvent::Scrolled(_, area, offset) = event {
// Inner and outer scroll notifications may come from the
// same frame. Accept both across the first epoch bump,
// while still rejecting another owner/document/revision.
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.revision == context.revision
}) && self.behavior.scenario.scrolled(area, offset)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if self.scenario_context() != Some(context) {
return Task::none();
}
match event {
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id)
== Some(context.scenario)
{
self.select(id);
self.selected_node = None;
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.scenario.choose_tool(context.scenario, tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status =
"Apply or cancel pending edits before changing the scenario."
.into();
return Task::none();
}
match mutation {
CanvasEvent::Begin {
slot,
sender,
receiver,
style,
..
} => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_message(
self.project(),
context,
slot,
sender,
receiver,
style,
);
self.behavior.scenario = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the message exchange and apply at the highlighted sequence position.".into();
}
Err(error) => self.status = error,
}
}
CanvasEvent::Reorder {
occurrence, slot, ..
} => {
match scenario::reorder(
self.project(),
context.scenario,
occurrence,
slot,
) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.selected_node = None;
self.refresh_draft();
self.status = "Occurrence reordered within its sequence · Undo restores the previous order".into();
}
}
Err(error) => self.status = error,
}
}
_ => unreachable!(),
}
}
}
}
Message::StateDiagram(event) => return self.edit_state_diagram(event),
Message::BehaviorOwner(choice) => {
if self.apply()
@@ -1124,6 +1461,32 @@ impl App {
self.status = e;
}
}
Message::FieldAppend(path, value) => {
if let Some(draft) = &mut self.draft
&& let Err(error) = draft.append(&path, value)
{
self.status = error;
}
}
Message::FieldValueBrowse(path, scope, page) => {
if let Some(draft) = &mut self.draft
&& draft
.value
.pointer(&path)
.is_some_and(|row| row.get("expression").is_some())
{
draft
.value_browsers
.insert(path, crate::forms::ValueBrowser { scope, page });
}
}
Message::FieldValuePath(path, key) => {
if let Some(draft) = &mut self.draft
&& let Err(error) = draft.value_path(&path, key)
{
self.status = error;
}
}
Message::FieldVariant(path, value) => {
if let Some(d) = &mut self.draft {
let result = d.materialize().and_then(|current| {
@@ -1384,6 +1747,19 @@ impl App {
self.fit();
}
}
Message::Data(event) => {
if self.data_state.update(&event) {
return Task::none();
}
if self.apply() {
match crate::data_view::command(self.project(), self.layer, event) {
Ok(command) => {
self.execute(command);
}
Err(error) => self.status = error,
}
}
}
Message::CreateKind(kind) => self.create_kind = kind,
Message::Add(child) => {
if self.apply() {
@@ -2203,7 +2579,14 @@ impl App {
Err(e) => e,
};
}
Message::WindowResized(size) => self.window_size = size,
Message::WindowResized(size) => {
// The canvas can retain its capped size during a real window
// resize. Invalidate any scenario pointer/slot gesture anyway.
if self.window_size != size && self.scenario_context().is_some() {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.window_size = size;
}
Message::CanvasResized(size) => {
let first_measurement = self.canvas_size.is_none();
let size_changed = self.canvas_size != Some(size);
@@ -2577,8 +2960,14 @@ impl App {
.discovered(&disk.bundle, disk.reachable.clone());
self.show_source_file(false);
self.source_diagnostics = self.document.compiled().diagnostics.clone();
self.status =
"Source project opened. Included files and comments are preserved.".into();
self.migration = self.document.compiled().decoded.migration.clone();
if self.migration.is_some() {
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
self.page = Page::Validation;
} else {
self.status =
"Source project opened. Included files and comments are preserved.".into();
}
}
Err(error) => {
self.canvas_document = Id::new_v4();
@@ -3208,7 +3597,9 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
&& let Some(source_files::DiskExpectation::Bytes(bytes)) =
disk.expectations.get(&disk.bundle.entry)
&& let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model)
&& decoded.migration.is_some()
// Source1/2 require the historical single-document adapters.
// Source3+ must retain its authored bundle and token migration.
&& decoded.migration.as_ref().is_some_and(|report| report.from_schema <= 2)
{
return Ok(LoadedSnapshot::Legacy(
path,
@@ -3293,6 +3684,97 @@ mod source_project_tests {
Ok(())
}
#[test]
fn source3_native_open_preserves_report_comments_nesting_and_save_reopen() {
let original = include_str!("../../oa-core/tests/fixtures/v3-data-contract.arcadia");
for split in [false, true] {
let directory = Directory::new();
let path = directory.0.join("project.arcadia");
let mut authored = std::collections::BTreeMap::new();
if split {
let start = original.find("{\n").unwrap() + 2;
let end = original.rfind('}').unwrap();
authored.insert(
"project.arcadia",
format!(
"{} include \"dictionary.arcadia\";\n}}\n",
&original[..start]
),
);
authored.insert("dictionary.arcadia", original[start..end].to_owned());
} else {
authored.insert("project.arcadia", original.to_owned());
}
for (file, source) in &authored {
std::fs::write(directory.0.join(file), source).unwrap();
}
let mut app = App::with_project(Project::empty());
send(
&mut app,
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
);
assert_eq!(app.migration.as_ref().unwrap().from_schema, 3);
assert_eq!(app.page, Page::Validation);
assert_eq!(app.path, Some(path.clone()));
assert_eq!(app.document.bundle().files.len(), authored.len());
assert!(
app.dirty(),
"accepted source migration needs explicit saving"
);
for (file, source) in &authored {
assert_eq!(
std::fs::read_to_string(directory.0.join(file)).unwrap(),
*source
);
}
let text = app
.document
.bundle()
.files
.values()
.cloned()
.collect::<String>();
assert!(text.contains("// Reused record parameters retain their own identities."));
assert!(text.contains("// Frozen authored schema3 compatibility input."));
let packet = app
.project()
.elements
.values()
.find(|e| e.alias == "packet")
.unwrap()
.id;
let origin = app
.project()
.elements
.values()
.find(|e| e.alias == "origin")
.unwrap()
.id;
assert_eq!(
app.document.compiled().index.lexical_owners.get(&origin),
Some(&packet)
);
let before = app.document.snapshot();
save(&mut app, path.clone()).unwrap();
assert!(!app.dirty());
for (file, source) in &before.bundle.files {
assert_eq!(
std::fs::read_to_string(directory.0.join(file)).unwrap(),
*source
);
}
let mut reopened = App::with_project(Project::empty());
send(
&mut reopened,
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
);
assert!(reopened.migration.is_none());
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.document.bundle(), app.document.bundle());
assert!(!reopened.dirty());
}
}
fn pending_journal(directory: &Directory, before: Option<&str>) -> (PathBuf, PathBuf) {
let path = directory.0.join("project.arcadia");
let journal = directory.0.join(".arcadia-save-project.arcadia.journal");
@@ -3847,3 +4329,50 @@ mod lint_tests {
assert!(app.project().elements.is_empty());
}
}
#[cfg(test)]
mod data_paging_lifecycle_tests {
use super::*;
#[test]
fn paging_preserves_same_context_and_resets_on_layer_or_document_replacement() {
let mut p = Project::empty();
let mut oa = None;
for layer in [Layer::Oa, Layer::Sa] {
for index in 0..70 {
let id = p
.add_element(
Kind::Interface,
layer,
&format!("Interface {layer} {index}"),
None,
)
.unwrap();
if layer == Layer::Oa {
oa = Some(id);
}
}
}
let mut app = App::with_project(p.clone());
let page = Message::Data(crate::data_view::Event::Page {
section: crate::data_view::Section::Catalog,
owner: None,
page: 2,
});
let _ = app.update(Message::Page(Page::Data));
let _ = app.update(page.clone());
let retained = app.data_state.clone();
let _ = app.update(Message::Page(Page::Catalog));
let _ = app.update(Message::Page(Page::Data));
let _ = app.update(Message::Layer(Layer::Oa));
assert_eq!(app.data_state, retained);
let _ = app.update(Message::Layer(Layer::Sa));
assert_eq!(app.data_state, crate::data_view::State::default());
let _ = app.update(page.clone());
let _ = app.update(Message::Select(oa.unwrap()));
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.data_state, crate::data_view::State::default());
let _ = app.update(page);
app.reset(p, None);
assert_eq!(app.data_state, crate::data_view::State::default());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -432,11 +432,12 @@ mod tests {
let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
for (interface, item) in [(provided, report), (required, result)] {
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
p.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
}
p.relate(
RelationKind::InterfaceProvision,
+559
View File
@@ -0,0 +1,559 @@
//! One address for a carried value, shared by scenario creation and property editing.
use oa_core::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FieldPathKey {
pub path: Vec<Id>,
pub field: Id,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldPathChoice {
pub key: FieldPathKey,
pub label: String,
pub picker_label: String,
}
impl std::fmt::Display for FieldPathChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.picker_label)
}
}
fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &MessageKind) -> bool {
data::item_mechanism(project, entry.item) != Some(ExchangeMechanism::Operation)
|| match phase {
MessageKind::Call => matches!(
entry.direction,
ParameterDirection::Unspecified
| ParameterDirection::In
| ParameterDirection::InOut
),
MessageKind::Reply => matches!(
entry.direction,
ParameterDirection::Unspecified
| ParameterDirection::Out
| ParameterDirection::InOut
| ParameterDirection::Return
),
_ => true,
}
}
fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice {
let qualified = entry
.path
.iter()
.chain(std::iter::once(&entry.field))
.map(|id| {
project
.elements
.get(id)
.map(|e| format!("{} (@{})", e.name, e.alias))
.unwrap_or_else(|| id.to_string())
})
.collect::<Vec<_>>();
let direction =
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
match entry.direction {
ParameterDirection::Unspecified => " · direction unspecified — review",
ParameterDirection::In => " · In",
ParameterDirection::Out => " · Out",
ParameterDirection::InOut => " · InOut",
ParameterDirection::Return => " · Return",
}
} else {
""
};
let terminal = qualified
.last()
.expect("A field path always has a terminal");
let owner_path = qualified[..qualified.len() - 1].join(" → ");
let picker_label = format!(
"{terminal}{direction} — {}{}",
project.named(entry.item),
if owner_path.is_empty() {
String::new()
} else {
format!(" / {owner_path}")
},
);
FieldPathChoice {
key: FieldPathKey {
path: entry.path,
field: entry.field,
},
label: format!(
"{} / {}{direction}",
project.named(entry.item),
qualified.join(" → ")
),
picker_label,
}
}
// A bounded picker must expose its distinguishing field before a long path.
// Keep the full address separately for the wrapping detail text.
fn sorted_choices(mut choices: Vec<FieldPathChoice>) -> Vec<FieldPathChoice> {
choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase());
choices
}
#[cfg(test)]
pub fn choices(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
) -> Result<Vec<FieldPathChoice>, String> {
Ok(sorted_choices(
data::try_carried_field_paths(project, exchange)?
.into_iter()
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
.collect(),
))
}
/// Direct carried fields only: an unrelated deep branch must not prevent
/// browsing a root field. Preserve the core resolver's first carried owner.
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
let mut seen = std::collections::BTreeSet::new();
sorted_choices(
exchange
.body
.items()
.iter()
.flat_map(|item| {
data::effective_fields(project, *item)
.into_iter()
.filter_map(|field| {
let SharedBody::DataField { direction, .. } = field.shared()? else {
return None;
};
Some(data::CarriedFieldPath {
item: *item,
path: vec![],
field: field.id,
direction: *direction,
})
})
})
.filter(|entry| seen.insert(entry.field))
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
.collect(),
)
}
/// Browse one record at a time, including explicitly chosen recursive hops.
pub fn at_scope(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
scope: Option<&FieldPathKey>,
) -> Result<Vec<FieldPathChoice>, String> {
match scope {
Some(key) => children(project, exchange, phase, key),
None => Ok(roots(project, exchange, phase)),
}
}
/// Resolve authored finite paths as well as the finite representative picker list.
/// In particular, optional recursive Value records may be traversed repeatedly.
pub fn resolve(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
key: &FieldPathKey,
) -> Option<FieldPathChoice> {
data::carried_value_path(project, exchange, &key.path, key.field)
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
}
pub fn parent(key: &FieldPathKey) -> Option<FieldPathKey> {
let (&field, path) = key.path.split_last()?;
Some(FieldPathKey {
path: path.to_vec(),
field,
})
}
/// Expand exactly one selected record. This keeps recursive authoring explicit
/// and finite, and never traverses Reference storage or manufactures value paths.
pub fn children(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
key: &FieldPathKey,
) -> Result<Vec<FieldPathChoice>, String> {
let Some(entry) = data::carried_value_path(project, exchange, &key.path, key.field)
.filter(|entry| phase_allows(project, entry, phase))
else {
return Ok(vec![]);
};
let Some(SharedBody::DataField {
type_ref: TypeRef::Named(record),
storage: FieldStorage::Value,
..
}) = project.elements.get(&key.field).and_then(Element::shared)
else {
return Ok(vec![]);
};
if !data::is_record(project, *record) {
return Ok(vec![]);
}
if key.path.len() >= data::MAX_VALUE_PATH_DEPTH {
return Err(format!(
"A value path can contain at most {} enclosing fields.",
data::MAX_VALUE_PATH_DEPTH
));
}
let mut path = key.path.clone();
path.push(key.field);
Ok(sorted_choices(
data::effective_fields(project, *record)
.into_iter()
.map(|field| {
named_choice(
project,
data::CarriedFieldPath {
item: entry.item,
path: path.clone(),
field: field.id,
direction: entry.direction,
},
)
})
.collect(),
))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub struct Payload {
pub project: Project,
pub exchange: Id,
pub item: Id,
pub leaf: Id,
pub origin: Id,
pub destination: Id,
pub scenario: Id,
}
pub fn payload() -> Payload {
let mut p = Project::empty();
let item = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Move", None)
.unwrap();
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Operation,
supertype: None,
};
let record = p
.add_element(Kind::DataType, Layer::Oa, "Coordinate", None)
.unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
let leaf = p
.add_element(Kind::DataField, Layer::Oa, "latitude", Some(record))
.unwrap();
let origin = p
.add_element(Kind::DataField, Layer::Oa, "origin", Some(item))
.unwrap();
let destination = p
.add_element(Kind::DataField, Layer::Oa, "destination", Some(item))
.unwrap();
for (id, type_ref, direction) in [
(leaf, TypeRef::Real, ParameterDirection::Unspecified),
(origin, TypeRef::Named(record), ParameterDirection::In),
(destination, TypeRef::Named(record), ParameterDirection::Out),
] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
direction,
minimum: 1,
maximum: Some(1),
unit: String::new(),
storage: FieldStorage::Value,
default: None,
};
}
let source = p
.add_element(Kind::Activity, Layer::Oa, "Request", None)
.unwrap();
let target = p
.add_element(Kind::Activity, Layer::Oa, "Perform", None)
.unwrap();
let exchange = p.relate(RelationKind::Interaction, "Movement", source, target);
p.relations.get_mut(&exchange).unwrap().body = RelationBody::Interaction {
source,
target,
items: vec![item],
};
let scenario = p
.add_element(Kind::Scenario, Layer::Oa, "Conversation", None)
.unwrap();
validate_candidate(&p).unwrap();
Payload {
project: p,
exchange,
item,
leaf,
origin,
destination,
scenario,
}
}
#[test]
fn phase_choices_keep_reused_record_addresses_distinct() {
let mut f = payload();
assert_eq!(
data::item_mechanism(&f.project, f.item),
Some(ExchangeMechanism::Operation)
);
let call = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Call,
)
.unwrap();
assert!(
call.iter()
.any(|c| c.key.path == [f.origin] && c.key.field == f.leaf)
);
assert!(!call.iter().any(|c| c.key.path == [f.destination]));
let reply = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Reply,
)
.unwrap();
assert!(
reply
.iter()
.any(|c| c.key.path == [f.destination] && c.key.field == f.leaf)
);
assert!(!reply.iter().any(|c| c.key.path == [f.origin]));
if let Some(SharedBody::DataField { direction, .. }) = f
.project
.elements
.get_mut(&f.destination)
.unwrap()
.shared_mut()
{
*direction = ParameterDirection::InOut;
}
let both = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Call,
)
.unwrap();
let leaves = both
.iter()
.filter(|c| c.key.field == f.leaf)
.collect::<Vec<_>>();
assert_eq!(leaves.len(), 2);
assert_ne!(leaves[0].key, leaves[1].key);
assert_ne!(leaves[0].label, leaves[1].label);
let values = leaves
.into_iter()
.map(|c| (c.key.clone(), "1".to_string()))
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(values.len(), 2);
}
#[test]
fn reference_parameter_stops_descent_and_unspecified_direction_remains_visible() {
let mut f = payload();
if let Some(SharedBody::DataField {
storage, direction, ..
}) = f.project.elements.get_mut(&f.origin).unwrap().shared_mut()
{
*storage = FieldStorage::Reference;
*direction = ParameterDirection::Unspecified;
}
let call = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Call,
)
.unwrap();
let direct = call.iter().find(|c| c.key.field == f.origin).unwrap();
assert!(direct.key.path.is_empty());
assert!(direct.label.contains("unspecified"));
assert!(!call.iter().any(|c| c.key.path.contains(&f.origin)));
}
pub fn recursive_payload() -> (Payload, Id) {
let mut f = payload();
let record = f.project.elements[&f.leaf].parent.unwrap();
let next = f
.project
.add_element(Kind::DataField, Layer::Oa, "next", Some(record))
.unwrap();
*f.project
.elements
.get_mut(&next)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::DataField {
type_ref: TypeRef::Named(record),
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
validate_candidate(&f.project).unwrap();
(f, next)
}
pub fn wide_payload() -> Payload {
let mut f = payload();
let record = f.project.elements[&f.leaf].parent.unwrap();
for (owner, template, count) in [(f.item, f.origin, 95), (record, f.leaf, 127)] {
for index in 0..count {
let body = f.project.elements[&template].shared().unwrap().clone();
let id = f
.project
.add_element(
Kind::DataField,
Layer::Oa,
&format!("Field {template} {index}"),
Some(owner),
)
.unwrap();
*f.project
.elements
.get_mut(&id)
.unwrap()
.shared_mut()
.unwrap() = body;
}
}
f
}
#[test]
fn roots_and_one_hop_children_do_not_expand_unrelated_payload_branches() {
let mut f = wide_payload();
let exchange = &f.project.relations[&f.exchange];
let direct = roots(&f.project, exchange, &MessageKind::Call);
assert_eq!(direct.len(), 96);
assert!(direct.iter().all(|choice| choice.key.path.is_empty()));
let representative = choices(&f.project, exchange, &MessageKind::Call).unwrap();
assert_eq!(representative.len(), 96 * 129);
for root in &direct {
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &root.key),
Some(root.clone())
);
}
let nested = children(&f.project, exchange, &MessageKind::Call, &direct[0].key).unwrap();
assert_eq!(nested.len(), 128);
for child in &nested {
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &child.key),
Some(child.clone())
);
assert!(child.label.ends_with(" · In"));
}
// A temporarily over-limit model still exposes its unrelated root fields;
// the editor must not expand the whole graph before showing a choice.
for index in 0..32 {
let body = f.project.elements[&f.origin].shared().unwrap().clone();
let id = f
.project
.add_element(
Kind::DataField,
Layer::Oa,
&format!("Extra {index}"),
Some(f.item),
)
.unwrap();
*f.project
.elements
.get_mut(&id)
.unwrap()
.shared_mut()
.unwrap() = body;
}
let exchange = &f.project.relations[&f.exchange];
assert!(choices(&f.project, exchange, &MessageKind::Call).is_err());
assert_eq!(roots(&f.project, exchange, &MessageKind::Call).len(), 128);
assert_eq!(
children(&f.project, exchange, &MessageKind::Call, &direct[0].key)
.unwrap()
.len(),
128
);
}
#[test]
fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() {
let (f, next) = recursive_payload();
let exchange = &f.project.relations[&f.exchange];
let record = FieldPathKey {
path: vec![f.origin],
field: next,
};
let nested = children(&f.project, exchange, &MessageKind::Call, &record).unwrap();
let leaf = nested.iter().find(|c| c.key.field == f.leaf).unwrap();
assert_eq!(leaf.key.path, vec![f.origin, next]);
assert!(
!choices(&f.project, exchange, &MessageKind::Call)
.unwrap()
.iter()
.any(|c| c.key == leaf.key)
);
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
Some(leaf.clone())
);
assert_eq!(parent(&leaf.key), Some(record));
let deepest = FieldPathKey {
path: std::iter::once(f.origin)
.chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1))
.collect(),
field: next,
};
assert!(resolve(&f.project, exchange, &MessageKind::Call, &deepest).is_some());
assert!(
children(&f.project, exchange, &MessageKind::Call, &deepest)
.unwrap_err()
.contains("128")
);
}
#[test]
fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() {
let (f, next) = recursive_payload();
let exchange = &f.project.relations[&f.exchange];
let context = FieldPathKey {
path: vec![f.origin, next, next],
field: next,
};
let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap();
let leaf = visible
.iter()
.find(|choice| choice.key.field == f.leaf)
.unwrap();
assert!(leaf.to_string().starts_with("latitude (@latitude)"));
assert!(leaf.label.contains("origin (@origin) → next (@next)"));
assert!(
visible.windows(2).all(
|pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase()
)
);
assert_ne!(
visible[0].to_string().chars().take(24).collect::<String>(),
visible[1].to_string().chars().take(24).collect::<String>()
);
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
Some(leaf.clone())
);
}
}
File diff suppressed because it is too large Load Diff
+181 -1
View File
@@ -292,6 +292,7 @@ impl DiagramCanvas<'_> {
request.placement = Some(node);
} else if let Some(edge) = self
.hit_label(&scene.routed, local)
.or_else(|| self.hit_association_label(&scene.routed, local))
.or_else(|| self.hit_edge(&scene.routed, local).map(|(id, _)| id))
{
request.object = edge;
@@ -369,6 +370,33 @@ impl DiagramCanvas<'_> {
})
.map(|edge| edge.id)
}
fn hit_association_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
let p = self.world(p);
routed
.edges
.values()
.flat_map(|edge| {
self.project
.relations
.get(&edge.id)
.into_iter()
.flat_map(move |relation| routing::association_end_geometry(relation, edge))
.map(move |end| (edge.id, end.label))
})
.filter(|(_, label)| {
Rectangle::new(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
)
.contains(p)
})
.min_by(|a, b| {
p.distance(point(a.1.center))
.total_cmp(&p.distance(point(b.1.center)))
.then(a.0.cmp(&b.0))
})
.map(|(id, _)| id)
}
fn preview_diagram(&self, state: &State) -> Diagram {
let mut diagram = self.project.diagrams[&self.diagram].clone();
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
@@ -725,7 +753,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
state.drag = Some((id, p, position));
state.preview = Some(position);
Some(Action::publish(Message::SelectNode(id)).and_capture())
} else if let Some(id) = self.hit_label(&scene.routed, p) {
} else if let Some(id) = self
.hit_label(&scene.routed, p)
.or_else(|| self.hit_association_label(&scene.routed, p))
{
Some(Action::publish(Message::Select(id)).and_capture())
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
if id == self.selected
@@ -1010,6 +1041,37 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
..Default::default()
});
}
if let Some(relation) = self.project.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, routed) {
if end.navigable {
let [a, tip, b] = navigation_mark(end.tip, end.inward);
f.stroke(
&Path::new(|p| {
p.move_to(a);
p.line_to(tip);
p.line_to(b);
}),
Stroke::default().with_color(color).with_width(1.7),
);
}
let label = &end.label;
f.fill_rectangle(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
Color::WHITE,
);
f.fill_text(Text {
content: label.text.clone(),
position: point(label.center),
color: if active { style::TEAL } else { style::MUTED },
size: 13.into(),
align_x: iced::widget::text::Alignment::Center,
align_y: iced::alignment::Vertical::Center,
max_width: f32::INFINITY,
..Default::default()
});
}
}
}
for n in &view.nodes {
if !containers.contains(&n.id) {
@@ -1257,6 +1319,17 @@ fn endpoint_position(view: &ProjectedView, id: Id) -> Option<Position> {
})
})
}
fn navigation_mark(tip: Position, inward: Position) -> [Point; 3] {
let tip = point(tip);
let delta = point(inward) - tip;
let direction = delta / delta.x.hypot(delta.y).max(0.001);
let normal = Vector::new(-direction.y, direction.x) * 4.;
[
tip + direction * 9. + normal,
tip,
tip + direction * 9. - normal,
]
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PortGlyph {
Function,
@@ -1544,6 +1617,21 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
if !label.text.is_empty() {
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
}
if let Some(relation) = p.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, route) {
let side = if end.source_end { "source" } else { "target" };
svg.push_str(&format!(
"<g data-association=\"{}\" data-end=\"{side}\">",
edge.id
));
if end.navigable {
let [a, tip, b] = navigation_mark(end.tip, end.inward);
svg.push_str(&format!("<path data-navigation=\"{side}\" d=\"M {} {} L {} {} L {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.7\"/>",a.x,a.y,tip.x,tip.y,b.x,b.y));
}
let label = &end.label;
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text></g>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
}
}
}
for n in &view.nodes {
if !containers.contains(&n.id) {
@@ -2353,6 +2441,98 @@ mod tests {
assert!(geometry.bounds.position.y <= route.label.bounds.position.y);
}
#[test]
fn association_end_contracts_share_svg_geometry_and_select_without_moving_name_label() {
for self_loop in [false, true] {
let mut p = Project::empty();
let a = p.add(Kind::DataType, "Tree", 100., 100.);
let b = if self_loop {
a
} else {
p.add(Kind::DataType, "Branch", 650., 100.)
};
for id in BTreeSet::from([a, b]) {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
}
let edge = p.relate(RelationKind::DataAssociation, "Hierarchy", a, b);
p.relations.get_mut(&edge).unwrap().body = RelationBody::DataAssociation {
source: a,
target: b,
source_role: "parent".into(),
target_role: "children".into(),
source_minimum: 0,
source_maximum: Some(1),
target_minimum: 0,
target_maximum: None,
source_navigable: false,
target_navigable: true,
};
let diagram = p.ensure_diagram(Layer::Oa);
let projected = project_view(&p, diagram);
assert!(
!projected
.edges
.iter()
.find(|e| e.id == edge)
.unwrap()
.oriented
);
let routed = route_diagram(&p, &p.diagrams[&diagram], &projected);
let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]);
assert_eq!(ends.len(), 2);
let svg = export_svg(&p, diagram);
assert!(svg.contains("parent [0..1]</text>"));
assert!(svg.contains("children [0..*]</text>"));
assert!(!svg.contains("data-navigation=\"source\""));
assert!(svg.contains("data-navigation=\"target\""));
let mut canvas = DiagramCanvas {
project: &p,
diagram,
selected: edge,
zoom: 0.7,
pan: Vector::new(350., 350.),
context: Context {
document: p.id,
diagram,
revision: 0,
epoch: 0,
},
selected_bend: None,
editable: true,
connection: None,
};
for end in ends {
assert!(svg.contains(&format!(
"x=\"{}\" y=\"{}\" text-anchor",
end.label.center.x, end.label.center.y
)));
let at = Point::new(
end.label.center.x * canvas.zoom + canvas.pan.x,
end.label.center.y * canvas.zoom + canvas.pan.y,
);
let mut state = State::default();
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
assert!(
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
);
assert!(
state.route_drag.is_none(),
"Endpoint contract labels never drag the association name"
);
assert_eq!(
canvas
.context_request(&state, at, Rectangle::with_size(Size::new(1800., 1200.)))
.object,
edge
);
canvas.selected = p.id;
}
}
}
#[test]
fn detached_label_selects_connection_and_occluding_card_owns_its_hit() {
let (mut p, diagram, edge) = route_fixture();
p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel {
+142 -13
View File
@@ -14,6 +14,7 @@ use iced::{
use oa_core::*;
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
mod data_fields;
mod port_fields;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -31,6 +32,12 @@ pub struct Input {
pub raw: String,
pub editor: text_editor::Content,
}
#[derive(Clone, Default)]
pub struct ValueBrowser {
pub scope: Option<crate::data_values::FieldPathKey>,
pub page: usize,
}
pub struct Draft {
pub kind: RecordKind,
pub original: Id,
@@ -39,6 +46,7 @@ pub struct Draft {
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
port_contract_drafts: BTreeMap<String, Value>,
pub value_browsers: BTreeMap<String, ValueBrowser>,
}
impl Draft {
@@ -76,11 +84,14 @@ impl Draft {
inputs: BTreeMap::new(),
exact_strings: false,
port_contract_drafts: BTreeMap::new(),
value_browsers: BTreeMap::new(),
};
draft.rebuild();
Some(draft)
}
fn rebuild(&mut self) {
// Array/variant edits can change the meaning of a row path.
self.value_browsers.clear();
self.inputs.clear();
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
match value {
@@ -218,7 +229,7 @@ impl Draft {
} else if path.ends_with("bindings") {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"field": Id::nil(), "expression": ""})
json!({"path": [], "field": Id::nil(), "expression": ""})
} else if path.ends_with("routes") {
json!({"edge": Id::nil(), "waypoints": [], "label": null})
} else if path.ends_with("waypoints") {
@@ -242,6 +253,33 @@ impl Draft {
self.rebuild();
Ok(())
}
pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> {
let mut candidate = self.materialize()?;
candidate
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?
.push(value);
self.value = candidate;
self.rebuild();
Ok(())
}
pub fn value_path(
&mut self,
path: &str,
key: crate::data_values::FieldPathKey,
) -> Result<(), String> {
let mut candidate = self.materialize()?;
let row = candidate
.pointer_mut(path)
.and_then(Value::as_object_mut)
.ok_or("Message value no longer exists.")?;
row.insert("path".into(), json!(key.path));
row.insert("field".into(), json!(key.field));
self.value = candidate;
self.rebuild();
Ok(())
}
pub fn command(&self) -> Result<Command, String> {
let mut value = self.materialize()?;
let decode = |e: serde_json::Error| e.to_string();
@@ -411,6 +449,7 @@ fn reference(path: &str) -> bool {
| "Named"
| "diagram"
| "machine"
| "supertype"
) || matches!(
parent,
"items"
@@ -440,6 +479,7 @@ fn optional_reference(path: &str) -> bool {
| "reply_to"
| "lifeline"
| "state"
| "supertype"
)
}
fn multiline(path: &str) -> bool {
@@ -462,9 +502,11 @@ fn free_string(path: &str) -> bool {
multiline(path)
|| matches!(
path.rsplit('/').next(),
Some("name" | "uri" | "revision" | "fragment" | "unit")
Some("name" | "uri" | "revision" | "fragment" | "unit" | "source_role" | "target_role")
)
|| path.split('/').rev().nth(1) == Some("literals")
|| (path.contains("/default/")
&& matches!(path.rsplit('/').next(), Some("String" | "Enumeration")))
}
// These are choices for the closed model, not a second validation boundary.
@@ -662,21 +704,42 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
.collect();
}
if field == "field" {
let address_path = path
.strip_suffix("/field")
.and_then(|row| value.pointer(&format!("{row}/path")))
.and_then(|v| serde_json::from_value::<Vec<Id>>(v.clone()).ok())
.unwrap_or_default();
return element
.as_ref()
.and_then(|e| match e.shared()? {
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(id), ..
exchange: Some(id),
message_kind,
..
},
..
} => project.relations.get(id),
} => project.relations.get(id).and_then(|r| {
let scope = address_path.split_last().map(|(&field, path)| {
crate::data_values::FieldPathKey {
path: path.to_vec(),
field,
}
});
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
}),
_ => None,
})
.map(|r| validation::carried_fields(project, r).into_iter().collect())
.unwrap_or_default();
.unwrap_or_default()
.into_iter()
.filter(|choice| choice.key.path == address_path)
.map(|choice| choice.key.field)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
}
let scenario = element
.as_ref()
.and_then(|e| e.parent)
@@ -707,6 +770,40 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
return false;
}
match field {
"supertype" => element.as_ref().is_some_and(|owner| {
if candidate.id == owner.id
|| candidate.kind() != owner.kind()
|| (candidate.kind() == Kind::DataType
&& !data::is_record(project, candidate.id))
{
return false;
}
if let (
Some(SharedBody::ExchangeItem {
kind, mechanism, ..
}),
Some(SharedBody::ExchangeItem {
kind: base_kind,
mechanism: base_mechanism,
..
}),
) = (owner.shared(), candidate.shared())
&& (kind != base_kind
|| (*base_mechanism != ExchangeMechanism::Unspecified
&& mechanism != base_mechanism))
{
return false;
}
let mut next = Some(candidate.id);
let mut seen = BTreeSet::new();
while let Some(id) = next {
if id == owner.id || !seen.insert(id) {
return false;
}
next = project.elements.get(&id).and_then(data::supertype);
}
true
}),
"machine" => candidate.kind() == Kind::StateMachine,
"parent" => element.as_ref().is_some_and(|child| {
if candidate.id == draft.original
@@ -1058,6 +1155,20 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
let words: &[&str] = match field {
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
"layer" => &["Oa", "Sa"],
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
"storage" => &["Value", "Reference"],
"direction" if path.contains("DataField") => {
&["Unspecified", "In", "Out", "InOut", "Return"]
}
"transmission" => &[
"Unspecified",
"Synchronous",
"Asynchronous",
"Unicast",
"Multicast",
"Broadcast",
],
"acquisition" => &["Unspecified", "Read", "Accept"],
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
"viewpoint" if path.contains("/Oa/") => &[
"OperationalActivity",
@@ -1066,7 +1177,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
],
"viewpoint" => &["Functional", "BoundaryInterface", "MixedLegacy"],
"phase" => &["Entry", "Do", "Exit"],
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"],
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy"],
"kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"],
"kind" if path.contains("State") => &[
"State",
@@ -1132,11 +1243,20 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
if field == "definition" {
return vec![
("Boolean".into(), json!("Boolean")),
("Integer".into(), json!("Integer")),
("Real".into(), json!("Real")),
("Text".into(), json!("String")),
(
"Integer".into(),
json!({"Integer":{"minimum":null,"maximum":null}}),
),
(
"Real".into(),
json!({"Real":{"minimum":null,"maximum":null}}),
),
(
"Text".into(),
json!({"String":{"minimum_length":0,"maximum_length":null}}),
),
("Enumeration".into(), json!({"Enumeration":{"literals":[]}})),
("Record".into(), json!("Record")),
("Record".into(), json!({"Record":{"supertype":null}})),
];
}
if field == "type_ref" {
@@ -1273,6 +1393,9 @@ fn property<'a>(
label: &str,
skip_variant: bool,
) -> View<'a, Message> {
if let Some(view) = data_fields::property(draft, project, value, path) {
return view;
}
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
if path.ends_with("/Port/legacy") {
let mut content = column![].spacing(4);
@@ -2075,6 +2198,8 @@ mod tests {
Layer::Oa,
SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy,
mechanism: ExchangeMechanism::Flow,
supertype: None,
},
);
let mut draft = Draft::from_project(&p, item).unwrap();
@@ -2091,8 +2216,12 @@ mod tests {
let interface = p
.add_element(Kind::Interface, Layer::Oa, "Power contract", None)
.unwrap();
p.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] });
p.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err());
assert!(draft.dirty());
assert_eq!(p.elements[&item].layer(), Layer::Oa);
+813
View File
@@ -0,0 +1,813 @@
//! Typed data contracts use the same draft and command boundary as other properties.
use super::*;
use iced::widget::column;
fn element(draft: &Draft, project: &Project) -> Option<Element> {
serde_json::from_value(draft.materialize().unwrap_or_else(|_| draft.value.clone()))
.ok()
.or_else(|| project.elements.get(&draft.original).cloned())
}
fn definition(project: &Project, reference: &TypeRef) -> Option<DataTypeDefinition> {
Some(match reference {
TypeRef::Boolean => DataTypeDefinition::Boolean,
TypeRef::Integer => DataTypeDefinition::Integer {
minimum: None,
maximum: None,
},
TypeRef::Real => DataTypeDefinition::Real {
minimum: None,
maximum: None,
},
TypeRef::String => DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
TypeRef::Named(id) => match project.elements.get(id)?.shared()? {
SharedBody::DataType { definition, .. } => definition.clone(),
_ => return None,
},
})
}
fn literal(definition: &DataTypeDefinition) -> Option<Value> {
Some(match definition {
DataTypeDefinition::Boolean => json!({"Boolean": false}),
DataTypeDefinition::Integer { minimum, .. } => json!({"Integer": minimum.unwrap_or(0)}),
DataTypeDefinition::Real { minimum, .. } => json!({"Real": minimum.unwrap_or(0.)}),
DataTypeDefinition::String { .. } => json!({"String": ""}),
DataTypeDefinition::Enumeration { literals } => json!({"Enumeration": literals.first()?}),
DataTypeDefinition::Record { .. } => return None,
})
}
pub(super) fn property<'a>(
draft: &'a Draft,
project: &'a Project,
value: &'a Value,
path: &str,
) -> Option<View<'a, Message>> {
if path.split('/').rev().nth(1) == Some("values") && value.is_object() {
return Some(message_value(draft, project, value, path));
}
let leaf = path.rsplit('/').next()?;
if leaf == "direction" && path.contains("/DataField/") {
let operation = element(draft, project)
.and_then(|e| e.parent)
.and_then(|id| data::item_mechanism(project, id))
== Some(ExchangeMechanism::Operation);
if !operation {
let mut out = column![
text("Parameter direction").size(11).color(style::MUTED),
text("Unspecified · direction applies to direct Operation parameters.").size(11)
]
.spacing(5);
if value.as_str() != Some("Unspecified") {
out = out
.push(
text(format!(
"Current {:?} requires review.",
value.as_str().unwrap_or_default()
))
.size(11),
)
.push(
button(text("Use Unspecified").size(11))
.style(style::nav)
.on_press(Message::FieldShape(path.into(), json!("Unspecified"))),
);
}
return Some(out.into());
}
}
let bounded = (path.contains("/definition/Integer/") || path.contains("/definition/Real/"))
&& matches!(leaf, "minimum" | "maximum");
let count_max = matches!(leaf, "maximum_length" | "source_maximum" | "target_maximum")
|| (leaf == "maximum" && path.contains("/DataField/"));
if bounded || count_max {
let label = match leaf {
"minimum" => "Inclusive lower bound",
"maximum" if bounded => "Inclusive upper bound",
"maximum_length" => "Maximum Unicode characters",
"source_maximum" => "Source maximum count",
"target_maximum" => "Target maximum count",
_ => "Maximum count",
};
let mut out = column![text(label).size(11).color(style::MUTED)].spacing(5);
if value.is_null() {
out = out.push(
button(
text(if bounded {
"Unbounded · set bound"
} else {
"Unlimited · set maximum"
})
.size(12),
)
.style(style::nav)
.on_press(Message::FieldShape(
path.into(),
if path.contains("/Real/") {
json!(0.)
} else if count_max {
let minimum_path = path.replace("maximum", "minimum");
json!(
draft
.materialize()
.ok()
.and_then(|v| v.pointer(&minimum_path).and_then(Value::as_u64))
.unwrap_or(1)
.max(1)
)
} else {
json!(0)
},
)),
);
} else if let Some(input) = draft.inputs.get(path) {
let owned = path.to_owned();
out = out
.push(
text_input("", &input.raw)
.on_input(move |s| Message::Field(owned.clone(), s))
.size(12)
.padding(8),
)
.push(
button(text("Clear bound").size(10))
.style(style::nav)
.on_press(Message::FieldShape(path.into(), Value::Null)),
);
}
return Some(out.into());
}
if leaf == "default" && (path.contains("/DataField/") || path.contains("/DataType/")) {
let owner = element(draft, project);
let (definition, collection, reference, minimum) =
match owner.as_ref().and_then(Element::shared) {
Some(SharedBody::DataType { definition, .. }) => {
(Some(definition.clone()), false, false, 0)
}
Some(SharedBody::DataField {
type_ref,
storage,
minimum,
..
}) => (
definition(project, type_ref),
true,
*storage == FieldStorage::Reference,
*minimum,
),
_ => (None, false, false, 0),
};
let initial = definition.as_ref().and_then(literal);
let mut out = column![text("Default").size(11).color(style::MUTED)].spacing(6);
if reference {
out = out.push(
text("References have no literal default; they identify an existing record.")
.size(11)
.color(style::MUTED),
);
}
if value.is_null() {
out = out.push(text("Absent · no authored default").size(12));
if !reference {
if let Some(initial) = initial {
out = out.push(
button(text("Set literal default").size(11))
.style(style::nav)
.on_press(Message::FieldShape(
path.into(),
if collection {
json!([initial])
} else {
initial
},
)),
);
}
if collection && minimum == 0 {
out = out.push(
button(text("Set explicit empty collection").size(11))
.style(style::nav)
.on_press(Message::FieldShape(path.into(), json!([]))),
);
}
}
} else {
out = out.push(
button(text("Clear default · make absent").size(11))
.style(style::nav)
.on_press(Message::FieldShape(path.into(), Value::Null)),
);
if let Some(values) = value.as_array() {
if values.is_empty() {
out = out.push(text("Explicit empty collection []").size(12));
}
for (i, value) in values.iter().enumerate() {
out = out
.push(literal_editor(
draft,
project,
value,
&format!("{path}/{i}"),
definition.as_ref(),
))
.push(
button(text("Remove value").size(10))
.style(style::nav)
.on_press(Message::FieldArray(path.into(), Some(i), 0)),
);
}
if let Some(initial) = initial
&& !reference
{
out = out.push(
button(text("+ Add literal").size(11))
.style(style::nav)
.on_press(Message::FieldAppend(path.into(), initial)),
);
}
} else {
out = out.push(literal_editor(
draft,
project,
value,
path,
definition.as_ref(),
));
}
}
return Some(out.into());
}
None
}
fn literal_editor<'a>(
draft: &'a Draft,
project: &'a Project,
value: &'a Value,
path: &str,
definition: Option<&DataTypeDefinition>,
) -> View<'a, Message> {
if let Some(DataTypeDefinition::Enumeration { literals }) = definition {
let current = draft
.materialize()
.ok()
.and_then(|v| {
v.pointer(&format!("{path}/Enumeration"))
.and_then(Value::as_str)
.map(str::to_owned)
})
.or_else(|| {
value
.get("Enumeration")
.and_then(Value::as_str)
.map(str::to_owned)
});
let mut choices = literals.clone();
if let Some(current) = &current
&& !choices.contains(current)
{
choices.push(current.clone());
}
let path = path.to_owned();
let picker = pick_list(choices, current, move |s| {
Message::FieldShape(path.clone(), json!({"Enumeration": s}))
})
.text_size(12)
.padding(8)
.width(Fill);
return if value.get("Enumeration").is_none() {
column![text("Current default uses another literal type. Choose an enumeration literal or clear the default.").size(11).color(style::MUTED),picker].spacing(5).into()
} else {
picker.into()
};
}
let mut out = column![].spacing(5);
if let Some(fields) = value.as_object() {
for (tag, payload) in fields {
out = out.push(super::property(
draft,
project,
payload,
&format!("{path}/{tag}"),
&friendly(tag),
true,
));
}
} else {
out = out.push(
text("Current literal requires review; clear it to choose a typed value.").size(11),
);
}
out.into()
}
fn message_value<'a>(
draft: &'a Draft,
project: &'a Project,
value: &'a Value,
path: &str,
) -> View<'a, Message> {
let edited = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let row = edited.pointer(path).unwrap_or(value).clone();
let current = serde_json::from_value::<MessageValue>(row.clone()).ok();
let owner = element(draft, project);
let context = owner
.as_ref()
.and_then(Element::shared)
.and_then(|body| match body {
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(id),
message_kind,
..
},
..
} => project.relations.get(id).map(|r| (r, message_kind)),
_ => None,
});
let current_key = current
.as_ref()
.map(|value| crate::data_values::FieldPathKey {
path: value.path.clone(),
field: value.field,
});
let selected = context.and_then(|(exchange, phase)| {
current_key
.as_ref()
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
});
let browser = draft
.value_browsers
.get(path)
.cloned()
.unwrap_or_else(|| ValueBrowser {
scope: selected
.as_ref()
.and_then(|choice| crate::data_values::parent(&choice.key)),
page: 0,
});
let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6);
if let Some(choice) = &selected {
out = out.push(text(choice.label.clone()).size(11));
} else if let Some(key) = &current_key
&& !key.field.is_nil()
{
out = out.push(
text(format!(
"Current address needs review: {} / {}. Choose an explicit field.",
key.path
.iter()
.map(|id| project.named(*id))
.collect::<Vec<_>>()
.join(" → "),
project.named(key.field)
))
.size(11)
.color(style::MUTED),
);
}
if let Some((exchange, phase)) = context {
let location = browser
.scope
.as_ref()
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
.map(|choice| choice.label)
.unwrap_or_else(|| "Root carried fields".into());
out = out.push(
text(format!("Browse: {location}"))
.size(11)
.color(style::MUTED),
);
if let Some(scope) = &browser.scope {
let mut navigation = row![button(text("↑ Up").size(11)).style(style::nav).on_press(
Message::FieldValueBrowse(path.into(), crate::data_values::parent(scope), 0)
)]
.spacing(4);
if !scope.path.is_empty() {
navigation = navigation.push(
button(text("Root fields").size(11))
.style(style::nav)
.on_press(Message::FieldValueBrowse(path.into(), None, 0)),
);
}
out = out.push(navigation);
}
match crate::data_values::at_scope(project, exchange, phase, browser.scope.as_ref()) {
Ok(choices) => {
let count = choices.len();
let (visible, page, pages) = field_page(choices, browser.page);
// Selection detail stays visible independently of browser location/page.
let visible_selection = selected
.clone()
.filter(|choice| visible.iter().any(|c| c.key == choice.key));
let owned = path.to_owned();
out = out.push(
pick_list(visible, visible_selection, move |choice| {
Message::FieldValuePath(owned.clone(), choice.key)
})
.placeholder("Choose a field here…")
.text_size(12)
.padding(8)
.width(Fill),
);
if pages > 1 {
out = out.push(
row![
button(text("Previous").size(11))
.style(style::nav)
.on_press_maybe((page > 0).then(|| Message::FieldValueBrowse(
path.into(),
browser.scope.clone(),
page - 1
))),
text(format!("{} / {pages} · {count} fields", page + 1)).size(11),
button(text("Next").size(11))
.style(style::nav)
.on_press_maybe((page + 1 < pages).then(|| {
Message::FieldValueBrowse(
path.into(),
browser.scope.clone(),
page + 1,
)
}))
]
.spacing(4)
.align_y(iced::Alignment::Center),
);
} else if count == 0 {
out = out.push(
text("No compatible fields at this location.")
.size(11)
.color(style::MUTED),
);
}
}
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
}
if let Some(choice) = &selected
&& browser.scope.as_ref() != Some(&choice.key)
&& crate::data_values::children(project, exchange, phase, &choice.key)
.is_ok_and(|children| !children.is_empty())
{
out = out.push(
button(
text(format!(
"Browse fields in {} →",
project.named(choice.key.field)
))
.size(11),
)
.style(style::nav)
.on_press(Message::FieldValueBrowse(
path.into(),
Some(choice.key.clone()),
0,
)),
);
}
} else {
out = out.push(
text("Choose a message exchange to browse its carried fields.")
.size(11)
.color(style::MUTED),
);
}
if let Some(expression) = value.get("expression") {
out = out.push(super::property(
draft,
project,
expression,
&format!("{path}/expression"),
"Value expression",
false,
));
}
out.into()
}
const FIELDS_PER_PAGE: usize = 24;
fn field_page(
choices: Vec<crate::data_values::FieldPathChoice>,
requested: usize,
) -> (Vec<crate::data_values::FieldPathChoice>, usize, usize) {
let pages = choices.len().div_ceil(FIELDS_PER_PAGE).max(1);
let page = requested.min(pages - 1);
(
choices
.into_iter()
.skip(page * FIELDS_PER_PAGE)
.take(FIELDS_PER_PAGE)
.collect(),
page,
pages,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() {
let (mut f, next) = crate::data_values::tests::recursive_payload();
if let Some(SharedBody::DataField { direction, .. }) = f
.project
.elements
.get_mut(&f.destination)
.unwrap()
.shared_mut()
{
*direction = ParameterDirection::InOut;
}
let call = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Call", Some(f.scenario))
.unwrap();
*f.project
.elements
.get_mut(&call)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Call,
reply_to: None,
values: vec![MessageValue {
path: vec![f.origin],
field: f.leaf,
expression: "old".into(),
}],
},
precondition: String::new(),
postcondition: String::new(),
};
f.project
.elements
.get_mut(&f.scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(call);
let mut app = crate::app::App::with_project(f.project.clone());
let path = "/body/Oa/Shared/Occurrence/body/Exchange/values/0";
let _ = app.update(Message::Select(call));
let _ = app.update(Message::Field(
format!("{path}/expression"),
"newly typed".into(),
));
let before_browse = app.draft.as_ref().unwrap().materialize().unwrap();
for (scope, page) in [
(None, 0),
(
Some(crate::data_values::FieldPathKey {
path: vec![f.origin],
field: next,
}),
999,
),
] {
let _ = app.update(Message::FieldValueBrowse(path.into(), scope, page));
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
assert_eq!(
app.draft.as_ref().unwrap().materialize().unwrap(),
before_browse
);
assert_eq!(app.project(), &f.project);
}
let _ = app.update(Message::FieldValuePath(
path.into(),
crate::data_values::FieldPathKey {
path: vec![f.destination],
field: f.leaf,
},
));
let _ = app.update(Message::Apply);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = app.project().elements[&call].shared()
else {
panic!()
};
assert_eq!(
values,
&vec![MessageValue {
path: vec![f.destination],
field: f.leaf,
expression: "newly typed".into()
}],
"{}",
app.status
);
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &f.project);
let nested = crate::data_values::children(
app.project(),
&app.project().relations[&f.exchange],
&MessageKind::Call,
&crate::data_values::FieldPathKey {
path: vec![f.origin],
field: next,
},
)
.unwrap()
.into_iter()
.find(|c| c.key.field == f.leaf)
.unwrap();
let _ = app.update(Message::Select(call));
let _ = app.update(Message::FieldValuePath(path.into(), nested.key.clone()));
let _ = app.update(Message::Apply);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = app.project().elements[&call].shared()
else {
panic!()
};
assert_eq!(values[0].path, vec![f.origin, next], "{}", app.status);
assert!(
crate::data_values::resolve(
app.project(),
&app.project().relations[&f.exchange],
&MessageKind::Call,
&nested.key
)
.is_some()
);
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
}
#[test]
fn field_browser_pages_every_direct_field_without_flattening_other_records() {
let f = crate::data_values::tests::wide_payload();
let exchange = &f.project.relations[&f.exchange];
let roots =
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, None).unwrap();
assert!(roots.len() > FIELDS_PER_PAGE);
assert!(roots.iter().all(|choice| choice.key.path.is_empty()));
let scope = crate::data_values::FieldPathKey {
path: vec![],
field: f.origin,
};
let children =
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, Some(&scope))
.unwrap();
assert!(children.len() > FIELDS_PER_PAGE);
assert!(
children
.iter()
.all(|choice| choice.key.path == vec![f.origin])
);
for choices in [roots, children] {
let (_, _, pages) = field_page(choices.clone(), 0);
let visited: Vec<_> = (0..pages)
.flat_map(|page| {
let (visible, actual, _) = field_page(choices.clone(), page);
assert_eq!(actual, page);
assert!(visible.len() <= FIELDS_PER_PAGE);
visible
})
.collect();
assert_eq!(visited, choices);
assert_eq!(field_page(choices, usize::MAX).1, pages - 1);
}
assert_eq!(field_page(vec![], usize::MAX), (vec![], 0, 1));
}
#[test]
fn numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state() {
let mut p = Project::empty();
let id = p
.add_element(Kind::DataType, Layer::Oa, "Counter", None)
.unwrap();
let mut draft = Draft::from_project(&p, id).unwrap();
let definition = "/body/Oa/Shared/DataType/definition";
draft
.shape(
definition,
json!({"Integer":{"minimum":null,"maximum":i64::MAX}}),
)
.unwrap();
let maximum = format!("{definition}/Integer/maximum");
draft.input(&maximum, "-".into());
assert!(draft.command().is_err());
assert!(draft.shape(&maximum, Value::Null).is_err());
assert_eq!(draft.inputs[&maximum].raw, "-");
draft.input(&maximum, "9223372036854775808".into());
assert!(draft.command().is_err());
draft.input(&maximum, i64::MAX.to_string());
draft
.shape(
"/body/Oa/Shared/DataType/default",
json!({"Integer":i64::MAX}),
)
.unwrap();
let accepted = execute_batch(&p, &[draft.command().unwrap()]).unwrap();
assert!(matches!(
accepted.elements[&id].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer {
maximum: Some(i64::MAX),
..
},
default: Some(DataValue::Integer(i64::MAX)),
..
})
));
draft.input(&maximum, "10".into());
assert!(execute_batch(&accepted, &[draft.command().unwrap()]).is_err());
assert_eq!(draft.inputs[&maximum].raw, "10");
}
#[test]
fn defaults_distinguish_absent_empty_and_typed_literals_with_escaped_unicode() {
let mut p = Project::empty();
let item = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Payload", None)
.unwrap();
let id = p
.add_element(Kind::DataField, Layer::Oa, "Optional names", Some(item))
.unwrap();
let mut draft = Draft::from_project(&p, id).unwrap();
let root = "/body/Oa/Shared/DataField";
draft.shape(&format!("{root}/minimum"), json!(0)).unwrap();
draft
.shape(&format!("{root}/maximum"), Value::Null)
.unwrap();
draft.shape(&format!("{root}/default"), json!([])).unwrap();
let empty = execute_batch(&p, &[draft.command().unwrap()]).unwrap();
assert!(
matches!(empty.elements[&id].shared(),Some(SharedBody::DataField {default:Some(v),..}) if v.is_empty())
);
draft
.shape(&format!("{root}/default"), Value::Null)
.unwrap();
let absent = execute_batch(&empty, &[draft.command().unwrap()]).unwrap();
assert!(matches!(
absent.elements[&id].shared(),
Some(SharedBody::DataField { default: None, .. })
));
draft
.shape(&format!("{root}/default"), json!([{"String":"é🙂\n"}]))
.unwrap();
draft.input(&format!("{root}/default/0/String"), "é🙂\n".into());
draft
.append(&format!("{root}/default"), json!({"String":"next"}))
.unwrap();
assert_eq!(
draft
.materialize()
.unwrap()
.pointer(&format!("{root}/default/0/String")),
Some(&json!("é🙂\n"))
);
draft.array(&format!("{root}/default"), Some(1), 0).unwrap();
draft.set_exact(true).unwrap();
let saved = execute_batch(&absent, &[draft.command().unwrap()]).unwrap();
assert!(
matches!(saved.elements[&id].shared(),Some(SharedBody::DataField {default:Some(v),..}) if v == &vec![DataValue::String("é🙂\n".into())])
);
let _ = super::super::view(&draft, &saved);
draft
.shape(&format!("{root}/storage"), json!("Reference"))
.unwrap();
assert!(execute_batch(&saved, &[draft.command().unwrap()]).is_err());
}
#[test]
fn supertype_picker_excludes_descendants_and_keeps_original_inherited_field_identity() {
let mut p = Project::empty();
let base = p
.add_element(Kind::DataType, Layer::Oa, "Base", None)
.unwrap();
let child = p
.add_element(Kind::DataType, Layer::Oa, "Child", None)
.unwrap();
for (id, supertype) in [(base, None), (child, Some(base))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
default: None,
unit: String::new(),
};
}
let field = p
.add_element(Kind::DataField, Layer::Oa, "Owned by base", Some(base))
.unwrap();
let draft = Draft::from_project(&p, base).unwrap();
let path = "/body/Oa/Shared/DataType/definition/Record/supertype";
assert!(!reference_ids(&draft, &p, path).contains(&child));
let child_draft = Draft::from_project(&p, child).unwrap();
assert!(reference_ids(&child_draft, &p, path).contains(&base));
assert_eq!(data::effective_fields(&p, child)[0].id, field);
assert_eq!(p.elements[&field].parent, Some(base));
}
}
+7 -9
View File
@@ -144,13 +144,7 @@ fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool {
}
let declared: Vec<_> = interfaces
.iter()
.filter_map(|id| project.elements.get(id))
.filter_map(|e| match e.shared() {
Some(SharedBody::Interface { items }) => Some(items),
_ => None,
})
.flatten()
.copied()
.flat_map(|id| data::interface_items(project, *id))
.collect();
items_satisfy(project, items, &declared)
}
@@ -714,8 +708,12 @@ mod tests {
interface,
);
assert!(reference_ids(&draft, &project, target).is_empty());
project.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![item] });
project.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
}
+24 -7
View File
@@ -369,7 +369,7 @@ fn article(topic: Topic) -> Article {
&[
"Create the source and target activities/functions in one perspective. Give each an engineering name and allocate leaf work to its responsible participant.",
"Choose Interaction or Functional exchange in the diagram palette. Click the source, then a highlighted compatible target, or drag between them. Functional exchanges connect output ports to input ports; function cards create or reuse real ports in the same transaction. Middle-button drag pans while keeping the source; arrow keys also pan when the drawing canvas has focus. Click empty space to restart, or Escape to cancel.",
"Create an Exchange item under Data & interfaces. Choose its kind (Information, Material, Energy or Event) and describe its payload.",
"Create an Exchange item under Data & interfaces. Choose its nature (Information, Material or Energy) and its mechanism (Flow, Event, Operation or Shared data). Describe the payload independently of the connection.",
"Select the exchange in Model explorer or the diagram. Edit its properties to reference the appropriate exchange items and available ports; Apply changes validates the complete record.",
"Choose Boundary exchange and connect compatible component ports. Flow goes from Out to In; Standard is unoriented. Select / move returns to arranging. Exchange allocation associates a boundary exchange with the functional exchange it carries.",
],
@@ -380,7 +380,7 @@ fn article(topic: Topic) -> Article {
),
explain(
"Use engineering payloads",
"Choose Information for data such as a distress report, Energy for delivered power, Material for a transferred substance and Event for a notification. Data types and fields can make structured payloads precise. URI artifact links can point to detailed interface-control documents.",
"Choose Information for data such as a distress report, Energy for delivered power and Material for a transferred substance. Mechanism separately describes how it is exchanged: an Event is a notification, an Operation has parameters, and a Flow carries values or a physical quantity. Data types and fields make payloads precise. Artifact links can point to interface-control documents.",
),
],
related: &[
@@ -510,17 +510,34 @@ fn article(topic: Topic) -> Article {
const {
Article {
summary: "Define exchanged payloads independently of the ports and participants that carry them. Reuse the same data definitions in related exchanges.",
keywords: "data interface port direction input output inout contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum",
keywords: "data interface port direction input output inout return contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum bounds default unit specialization inheritance association storage reference recursive path transmission acquisition protocol",
sections: &[
walkthrough(
"Define a reusable payload",
&[
"Open Data & interfaces and create an Exchange item. Select Information, Material, Energy or Event to describe the nature of the exchange.",
"Create a Data type where the payload needs a named definition. Choose its Data type definition and edit the corresponding values in Properties.",
"For structured definitions, create Data fields under a legal Data type or Exchange item owner. Set Value type and the minimum/maximum count as required by the contract.",
"Open Data & interfaces and create an Exchange item. Choose Information, Material or Energy for nature; choose Flow, Event, Operation or Shared data for mechanism. Keep Unspecified when the engineering decision is still open.",
"Create a Data type for a reusable definition. Choose Boolean, Integer, Real, String, Enumeration or Record. Numeric bounds are inclusive; String bounds count Unicode characters. A typed default must satisfy those bounds or enumeration literals. Units annotate quantities without converting them.",
"Create fields within an Exchange item or Record type. Set Value type, minimum/maximum count, unit and optional default. A default can be absent, explicitly empty when minimum is zero, or a list of typed scalar values. A type default describes one element; it does not create a required collection.",
"Select the exchange and reference the payload items. Apply changes and check the result in Model checks.",
],
),
explain(
"Operation parameters and message values",
"An Operation's direct fields are parameters. In and InOut supply call values; Out, InOut and Return supply reply values, from the provider's perspective. There is at most one Return parameter. Qualified paths distinguish origin.latitude from destination.latitude when both use the same Coordinate record. Nested fields inherit the parameter direction. Message expressions are annotations, not evaluated values.",
),
explain(
"Reuse, ownership and references",
"A Record or Exchange item can specialize one compatible base. Inherited fields keep their original owner and identity; edit the base to change them. Value fields compose a payload. Reference fields refer to separately identified Record objects and stop payload expansion. Optional recursive values and reference cycles are valid; a mandatory value cycle warns that no finite complete value exists. Data associations describe named reference ends, multiplicities and navigation between Record types without creating contained fields.",
),
walkthrough(
"Allocate an item to interfaces",
&[
"Create an Interface and allocate an Exchange item. Select its allocation row to edit transmission, acquisition and order; Open item navigates to the shared payload definition.",
"Two interfaces may allocate the same item with different protocols. Attach CAD, code or document evidence to the allocation when it concerns that particular contract.",
"Removing an allocation keeps the shared item and other allocations. If a live boundary depends on it, first revise that boundary contract. Undo restores an accepted removal.",
"An Interface may specialize one base and inherit its allocations. Duplicate allocation of an already inherited item is rejected. A specialized item satisfies its base contract; a base item does not satisfy a more specific contract.",
],
),
walkthrough(
"Connect participant boundaries",
&[
@@ -723,7 +740,7 @@ fn article(topic: Topic) -> Article {
),
explain(
"Open and migration",
"Open reads and validates a complete candidate before replacing the current project. Supported legacy files are migrated explicitly; save the resulting current format under a new name if the legacy original must remain available. Unsupported or invalid input does not replace the accepted project.",
"Open validates a complete candidate before replacing the project. Versions 1, 2 and 3 migrate explicitly to version 4; Model checks shows preserved ports, supplied data defaults, generated interface allocations and unresolved message paths. Old Event items become Information notifications; unknown physical mechanisms remain Unspecified. Source3 upgrades retain files, includes, nested ownership and comments. Opening does not overwrite the original: use Save as if it must remain in its older format. Invalid input leaves the accepted project unchanged.",
),
],
related: &[Topic::Source, Topic::Recovery, Topic::Artifacts],
+2
View File
@@ -4,6 +4,8 @@ mod behavior;
mod connection_edit;
mod connection_tool;
mod context_menu;
mod data_values;
mod data_view;
mod diagram;
mod document;
mod explorer;
+20 -13
View File
@@ -157,12 +157,9 @@ pub fn port_context_rows(project: &Project, port: Id) -> Vec<String> {
}
if let Some(legacy) = legacy {
rows.push(legacy_line(&context, legacy));
if let Some(interface) = legacy.interface
&& let Some(SharedBody::Interface { items }) =
context.element(interface).and_then(Element::shared)
{
for item in items {
origins.entry(*item).or_default().push(format!(
if let Some(interface) = legacy.interface {
for item in data::interface_items(project, interface) {
origins.entry(item).or_default().push(format!(
"preserved interface {} (suggestion only; not an active typed contract)",
context.label(interface)
));
@@ -711,11 +708,17 @@ mod tests {
let (other_fe, commands) = exchange(&mut project, source, target, vec![second]);
project = execute_batch(&project, &commands).unwrap();
let interface = add(&mut project, Kind::Interface, "Incident service");
project.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in(
Layer::Sa,
SharedBody::Interface {
items: vec![first, second],
},
project.relate(
RelationKind::InterfaceItemAllocation,
"First item",
interface,
first,
);
project.relate(
RelationKind::InterfaceItemAllocation,
"Second item",
interface,
second,
);
let connection = &project.relations[&boundary];
let (a, b) = (connection.source(), connection.target());
@@ -790,8 +793,12 @@ mod tests {
..
} = fixture();
let interface = add(&mut project, Kind::Interface, "Preserved contract");
project.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![first] });
project.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
first,
);
let legacy = port(
&mut project,
source,
+3 -6
View File
@@ -168,12 +168,9 @@ impl State {
_ => {}
}
}
if let Some(SharedBody::Interface { items }) = old
.and_then(|old| old.interface)
.and_then(|id| p.elements.get(&id))
.and_then(|e| e.shared())
{
incoming.extend(items);
if let Some(interface) = old.and_then(|old| old.interface) {
let items = data::interface_items(p, interface);
incoming.extend(items.iter().copied());
outgoing.extend(items);
}
for r in p.relations.values() {
+1 -1
View File
@@ -250,7 +250,7 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
assert_eq!(app.page, Page::Validation);
assert_eq!(app.project(), &original);
assert!(!app.dirty());
assert!(app.source_snapshot.starts_with("arcadia 3;"));
assert!(app.source_snapshot.starts_with("arcadia 4;"));
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
}
+46 -63
View File
@@ -703,69 +703,7 @@ fn inspector(app: &App) -> View<'_, Message> {
}
fn data_catalog(app: &App) -> View<'_, Message> {
let p = app.project();
let mut content = column![heading(
"Data & interfaces",
"Define information, material and energy at the system boundary."
)]
.spacing(15);
for kind in Kind::ALL {
let is_data = matches!(
kind,
Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port
);
if !is_data {
continue;
}
let elements = p
.elements
.values()
.filter(|e| e.layer() == app.layer && e.kind() == kind)
.collect::<Vec<_>>();
if elements.is_empty() {
continue;
}
content = content.push(
text(format!("{} · {}", kind.label(), elements.len()))
.size(15)
.color(style::kind_color(kind)),
);
for e in elements {
content = content.push(crate::context_menu::Area::new(
button(
row![
column![
text(&e.name).size(14),
label(format!(
"@{}{}",
e.alias,
e.parent
.map(|id| format!(" · in {}", p.named(id)))
.unwrap_or_default()
)),
text(short(&e.description, 125))
.size(12)
.color(style::MUTED)
]
.spacing(5)
.width(Fill),
text("→").size(17)
]
.align_y(iced::Center),
)
.padding(14)
.width(Fill)
.style(if e.id == app.selected {
style::selected
} else {
style::nav
})
.on_press(Message::Select(e.id)),
move |anchor| crate::model_menu::open(e.id, anchor),
));
}
}
scrollable(content.padding(24)).into()
crate::data_view::view(app)
}
fn traces(app: &App) -> View<'_, Message> {
@@ -1099,6 +1037,51 @@ fn checks(app: &App) -> View<'_, Message> {
app.project().named(endpoint.exchange)
)));
}
card=card.push(label(format!("Data migration: {} semantic defaults · {} interface allocations · {} unresolved value addresses",report.semantic_defaults.len(),report.generated_interface_allocations.len(),report.unresolved_message_values.len())));
for default in report.semantic_defaults.iter().take(6) {
card = card
.push(action(
&format!(
"{} · {}",
app.project().named(default.object),
default.field
),
Message::Select(default.object),
))
.push(label(&default.description));
}
for allocation in report.generated_interface_allocations.iter().take(6) {
card = card
.push(action(
&format!(
"Created item allocation · @{} · order {}",
allocation.alias, allocation.order
),
Message::Select(allocation.id),
))
.push(label(format!(
"{} → {}",
app.project().named(allocation.source),
app.project().named(allocation.target)
)));
}
for value in report.unresolved_message_values.iter().take(6) {
card = card
.push(action(
&format!(
"Review qualified value · {}",
app.project().named(value.occurrence)
),
Message::EditProperties(value.occurrence),
))
.push(label(&value.reason));
}
if report.semantic_defaults.len() > 6
|| report.generated_interface_allocations.len() > 6
|| report.unresolved_message_values.len() > 6
{
card=card.push(label("Showing the first six entries in each data migration category. Model checks below link remaining unresolved contracts."));
}
for boundary in &report.orientation_defaults {
card = card.push(action(
&format!(
@@ -315,3 +315,420 @@ async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_sav
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
server.stop().await;
}
#[tokio::test]
async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_source_and_restart()
{
use oa_core::{
Element, FieldStorage, MessageKind, MessageValue, OccurrenceBody, ParameterDirection,
PortContract, RelationBody, ScenarioViewpoint, TypeRef,
};
fn values(model: &Project, occurrence: Id) -> &[MessageValue] {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = model.elements[&occurrence].shared()
else {
panic!("Expected message");
};
values
}
fn edited_values(model: &Project, occurrence: Id, next: Vec<MessageValue>) -> Element {
let mut element = model.elements[&occurrence].clone();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = element.shared_mut()
else {
panic!("Expected message");
};
*values = next;
element
}
let mut model = documented_data();
let operation = id(&model, "navigate");
let coordinate = id(&model, "coordinate");
let located = id(&model, "located");
let latitude = id(&model, "latitude");
let origin = id(&model, "origin");
let destination = id(&model, "destination");
let accepted = id(&model, "accepted");
// The second parameter inherits the very same latitude identity.
let Some(SharedBody::DataField { type_ref, .. }) =
model.elements.get_mut(&destination).unwrap().shared_mut()
else {
panic!();
};
*type_ref = TypeRef::Named(located);
let next = model
.add_element(
Kind::DataField,
Layer::Sa,
"Next coordinate",
Some(coordinate),
)
.unwrap();
*model.elements.get_mut(&next).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref: TypeRef::Named(coordinate),
minimum: 0,
maximum: Some(1),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
unit: String::new(),
default: None,
};
let requester = model
.add_element(Kind::Function, Layer::Sa, "Request navigation", None)
.unwrap();
let provider = model
.add_element(Kind::Function, Layer::Sa, "Provide navigation", None)
.unwrap();
let output = Id::new_v4();
let input = Id::new_v4();
for (port, contract) in [
(
output,
PortContract::FunctionOutput {
outgoing_items: vec![operation],
},
),
(
input,
PortContract::FunctionInput {
incoming_items: vec![operation],
},
),
] {
let (owner, name) = if port == output {
(requester, "Navigation output")
} else {
(provider, "Navigation input")
};
let element = oa_core::ports::make_port(&model, port, owner, name, contract).unwrap();
model.elements.insert(port, element);
}
let exchange = model.relate(
RelationKind::FunctionalExchange,
"Navigation request",
requester,
provider,
);
model.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
source: requester,
target: provider,
source_port: output,
target_port: input,
items: vec![operation],
};
let scenario = model
.add_element(Kind::Scenario, Layer::Sa, "Navigation conversation", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
model.elements.get_mut(&scenario).unwrap().shared_mut()
else {
panic!();
};
*viewpoint = ScenarioViewpoint::Functional;
let sender = model
.add_element(Kind::Lifeline, Layer::Sa, "Requester line", Some(scenario))
.unwrap();
let receiver = model
.add_element(Kind::Lifeline, Layer::Sa, "Provider line", Some(scenario))
.unwrap();
for (line, represented) in [(sender, requester), (receiver, provider)] {
*model.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let call = model
.add_element(
Kind::Occurrence,
Layer::Sa,
"Navigation call",
Some(scenario),
)
.unwrap();
let reply = model
.add_element(
Kind::Occurrence,
Layer::Sa,
"Navigation reply",
Some(scenario),
)
.unwrap();
for (occurrence, message_kind, from, to, reply_to) in [
(call, MessageKind::Call, sender, receiver, None),
(reply, MessageKind::Reply, receiver, sender, Some(call)),
] {
*model
.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: Some(from),
receiver: Some(to),
message_kind,
reply_to,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
}
model
.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.extend([call, reply]);
oa_core::validate_candidate(&model).unwrap();
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (resource, _) = server.import(&model).await;
let before = server.get(resource).await;
let call_values = vec![
MessageValue {
path: vec![origin],
field: latitude,
expression: " origin.latitude\n+ 0.25 ".into(),
},
MessageValue {
path: vec![destination],
field: latitude,
expression: "destination.latitude".into(),
},
MessageValue {
path: vec![origin, next],
field: latitude,
expression: "previous.latitude".into(),
},
];
let reply_values = vec![MessageValue {
path: vec![],
field: accepted,
expression: "true".into(),
}];
let response = commands(
&server,
&before,
vec![
Command::EditElement(call, edited_values(&model, call, call_values.clone())),
Command::EditElement(reply, edited_values(&model, reply, reply_values.clone())),
],
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let authored = server.get(resource).await;
assert_eq!(authored.revision, before.revision + 1);
assert_eq!(values(&authored.model, call), call_values);
assert_eq!(values(&authored.model, reply), reply_values);
assert_eq!(
authored.model.elements[&latitude],
model.elements[&latitude]
);
assert_pair(&authored);
let filename = directory.path().join(format!("{resource}.json"));
let bytes = std::fs::read(&filename).unwrap();
for (occurrence, value, code) in [
(
call,
MessageValue {
path: vec![],
field: accepted,
expression: "wrong call phase".into(),
},
"message_parameter_phase",
),
(
reply,
MessageValue {
path: vec![origin],
field: latitude,
expression: "wrong reply phase".into(),
},
"message_parameter_phase",
),
(
call,
MessageValue {
path: vec![origin, accepted],
field: latitude,
expression: "unrelated hop".into(),
},
"message_value_field",
),
] {
let invalid = edited_values(&authored.model, occurrence, vec![value]);
let mut invalid_model = authored.model.clone();
invalid_model.elements.insert(occurrence, invalid.clone());
let response = commands(
&server,
&authored,
vec![Command::EditElement(occurrence, invalid)],
)
.await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let problem: Value = response.json().await.unwrap();
assert_eq!(problem["code"], "command_rejected", "{problem}");
assert!(
problem["detail"].as_str().unwrap().starts_with(code),
"{problem}"
);
// Transactions report the first rejection; validation supplies located issues.
let validation: Value = server
.client
.post(server.url(&format!("/projects/{resource}/validation")))
.json(&json!({"format":"json", "model":invalid_model}))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap();
assert_eq!(validation["valid"], false);
assert!(
validation["issues"]
.as_array()
.unwrap()
.iter()
.any(|issue| issue["code"] == code
&& issue["object_id"] == json!(occurrence)
&& issue["field"] == "values.path"),
"{problem}"
);
let unchanged = server.get(resource).await;
assert_eq!(unchanged.etag(), authored.etag());
assert_eq!(unchanged.model, authored.model);
assert_eq!(unchanged.source_project, authored.source_project);
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
}
// Source transport changes one argument without conflating the shared leaf.
let source = server
.client
.get(server.url(&format!("/projects/{resource}/source")))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.text()
.await
.unwrap();
let edited_source = source.replace(
"\"destination.latitude\"",
"\"destination.corrected_latitude\"",
);
assert_ne!(edited_source, source);
let response = server
.client
.put(server.url(&format!("/projects/{resource}/source")))
.header("if-match", authored.etag())
.header("content-type", "text/plain")
.body(edited_source)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let source_edited = server.get(resource).await;
assert_eq!(source_edited.revision, authored.revision + 1);
let mut expected_call = call_values.clone();
expected_call[1].expression = "destination.corrected_latitude".into();
assert_eq!(values(&source_edited.model, call), expected_call);
assert_eq!(values(&source_edited.model, reply), reply_values);
assert_pair(&source_edited);
// Reidentify every kind of enclosing hop, the shared terminal, and its base type.
let mapping = [origin, destination, next, latitude, coordinate]
.into_iter()
.map(|old| (old, Id::new_v4()))
.collect::<BTreeMap<_, _>>();
let response = commands(
&server,
&source_edited,
mapping
.iter()
.map(|(old, new)| Command::Reidentify(*old, *new))
.collect(),
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let changed = server.get(resource).await;
assert_eq!(changed.revision, source_edited.revision + 1);
for value in &mut expected_call {
value.path = value.path.iter().map(|hop| mapping[hop]).collect();
value.field = mapping[&value.field];
}
assert_eq!(values(&changed.model, call), expected_call);
assert_eq!(values(&changed.model, reply), reply_values);
assert_eq!(
changed.model.elements[&mapping[&latitude]].parent,
Some(mapping[&coordinate])
);
assert!(
matches!(changed.model.elements[&located].shared(), Some(SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: Some(base) }, ..
}) if *base == mapping[&coordinate])
);
assert!(
oa_core::data::effective_fields(&changed.model, located)
.iter()
.any(|field| field.id == mapping[&latitude])
);
for old in mapping.keys() {
assert!(!changed.model.contains(*old));
}
assert!(
matches!(changed.model.elements[&reply].shared(), Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { reply_to: Some(correlation), .. }, ..
}) if *correlation == call)
);
assert_pair(&changed);
let source = server
.client
.get(server.url(&format!("/projects/{resource}/source")))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 4;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model);
let saved = std::fs::read(&filename).unwrap();
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(resource).await;
assert_eq!(reopened.etag(), changed.etag());
assert_eq!(reopened.model, changed.model);
assert_eq!(reopened.source_project, changed.source_project);
assert_eq!(values(&reopened.model, call), expected_call);
assert_eq!(values(&reopened.model, reply), reply_values);
assert_pair(&reopened);
assert_eq!(std::fs::read(&filename).unwrap(), saved);
server.stop().await;
}
+4 -3
View File
@@ -984,9 +984,10 @@ declares <span class="docutils literal">outgoing_items</span>; <span class="docu
The lists contain distinct same-layer ExchangeItem references. A functional
exchange MUST reference output/input ports owned by its respective functions,
and each carried item MUST fit both declarations. Empty declarations accept
no items. Item compatibility is identity equality; future specialization is
not inferred from names. Port declarations remain independent of incident
exchanges after creation.</p>
no items. A carried item satisfies an identical declaration or a declaration
of one of its explicit base items. A base item does not satisfy a more
specialized declaration; names imply no compatibility. Port declarations
remain independent of incident exchanges after creation.</p>
<p>A <span class="docutils literal">Component</span> contract belongs to an SA System or SystemActor and stores
<span class="docutils literal">kind</span> (Standard or Flow) and <span class="docutils literal">orientation</span> (Unset, In, Out, InOut).
Standard requires Unset. Flow Unset is an incomplete draft, not InOut. Typed
+4 -3
View File
@@ -311,9 +311,10 @@ declares ``outgoing_items``; ``FunctionInput`` declares ``incoming_items``.
The lists contain distinct same-layer ExchangeItem references. A functional
exchange MUST reference output/input ports owned by its respective functions,
and each carried item MUST fit both declarations. Empty declarations accept
no items. Item compatibility is identity equality; future specialization is
not inferred from names. Port declarations remain independent of incident
exchanges after creation.
no items. A carried item satisfies an identical declaration or a declaration
of one of its explicit base items. A base item does not satisfy a more
specialized declaration; names imply no compatibility. Port declarations
remain independent of incident exchanges after creation.
A ``Component`` contract belongs to an SA System or SystemActor and stores
``kind`` (Standard or Flow) and ``orientation`` (Unset, In, Out, InOut).
+3 -3
View File
@@ -8,12 +8,12 @@ Arcadia Studio implements a systems-engineering workbench spanning Operational A
|---|---|
| Operational Analysis | Entities, actors, roles, activities, missions, capabilities and processes; entity/work decomposition; leaf allocation; role realization/assignment; interactions and capability/process involvement |
| System Analysis | One System of Interest, external Actors, functions, missions, capabilities and functional chains; functional decomposition independent of the system boundary; allocation, functional/boundary exchanges and mission-capability links |
| Data and interfaces | Information/material/energy/event exchange items, primitive/enum/record data types, typed fields with multiplicity/units, separate function input/output and Standard/Flow component ports, interface provision/requirement, port and exchange allocations |
| Data and interfaces | Information/material/energy nature and Flow/Event/Operation/SharedData mechanisms, bounded primitive/enum/record types, typed defaults, field multiplicity/units/direction/storage, specialization, identified interface allocations and reference associations, separate function input/output and Standard/Flow component ports, interface provision/requirement, port and exchange allocations |
| Behavior | Process/chain work and exchange contributions, control topology and sequence reuse; scenario lifelines, correlated calls/replies, timers, action/state occurrences, ALT/PAR/LOOP/OPTIONAL fragments, scenario reuse and pre/postconditions |
| States and conditions | Participant-owned state machines, nested regions, states/modes and typed pseudostates, signal/time/change events, trigger/guard/effect transitions, linked entry/do/exit work, availability and constraints |
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties |
| Text and storage | `arcadia 3` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
| Text and storage | `arcadia 4` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
| Automation | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage |
Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically.
@@ -45,7 +45,7 @@ These are concrete design responses, not claims that every Capella user experien
## Required first-release work
Direct state/scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
## Remaining boundaries
+2 -2
View File
@@ -1,6 +1,6 @@
# Arcadia Studio server
`arcadia-server` is a headless Rust REST service for the same schema-3 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each model and its authored source project together in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed.
`arcadia-server` is a headless Rust REST service for the same schema-4 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each model and its authored source project together in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed.
The complete HTTP contract is [api.rst](api.rst); machine-readable schemas are in [openapi.json](openapi.json), also served at `/api/v1/openapi.json`. The original [service research](research-v2-service.md) discusses broader alternatives; retained history, SQLite, idempotency receipts and stored transition plans from that research are **not** the implemented storage contract.
@@ -44,7 +44,7 @@ The data directory contains `.arcadia.lock` and one `<resource-id>.json` file pe
Startup validates every committed envelope, its filename/UUID, revision, size and complete model. Any corrupt or unsupported envelope stops startup; the service does not silently skip bad data or invent an empty replacement. Sibling `.arcadia-write-*` files from an interrupted pre-commit write are not committed models and are ignored on startup.
Earlier model-only envelopes are accepted through the existing model migrations and receive deterministic source files in memory. Opening, reading or an exact no-op neither rewrites those envelopes nor advances their revision. The next changed edit persists the coupled representation. Already-coupled envelopes must contain stable source IDs and matching model/source; inconsistent pairs are rejected at startup. Capacity accounting includes source generated in memory for old envelopes, so an over-capacity old store fails explicitly without rewriting or pruning it.
Earlier model-only envelopes are accepted through the existing model migrations and receive deterministic source files in memory. Opening, reading or an exact no-op neither rewrites those envelopes nor advances their revision. The next changed edit persists the coupled representation. Already-coupled schema3 envelopes explicitly migrate both representations in memory; the independently migrated graphs must agree. Current-format source must contain stable IDs. Inconsistent pairs are rejected at startup. Migration reports remain visible until a current-format edit/save; opening alone never rewrites the envelope. Capacity accounting includes source generated in memory for old envelopes, so an over-capacity old store fails explicitly without rewriting or pruning it.
Parsing, validation and blocking disk operations run on blocking workers. A shared store mutex captures snapshots and serializes final revision checks and writes. Parsing, semantic validation, source writeback and query processing use the captured snapshot outside that lock; an expensive candidate does not block the catalog or another model's preparation. Commit rechecks the exact ETag, so a candidate prepared against a superseded snapshot returns `412 revision_conflict`. A successful changed update increments revision once. Both model and source bundle must be unchanged for `changed: false`; comment-only edits advance the same revision. Each write serializes the whole envelope to a sibling temporary file, syncs it, and atomically replaces the destination. Unix also syncs the directory. The in-memory snapshot changes only after the replacement succeeds. A directory-sync error after replacement is reported as an uncertain write outcome while the in-memory head still matches the installed file.
-106
View File
@@ -1,106 +0,0 @@
# 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
@@ -1,396 +0,0 @@
# 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
@@ -1,12 +0,0 @@
[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
@@ -1,118 +0,0 @@
# 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.
@@ -1,28 +0,0 @@
# 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
@@ -1,934 +0,0 @@
//! 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"
);
}
}
@@ -1,909 +0,0 @@
//! 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
+6
View File
@@ -63,3 +63,9 @@ State camera/tool lifetime follows effective machine and diagram identity, even
when selection changes through Catalog or Layer outside Behavior. Reset only on
actual context change; preserve same-view return. Independent review reproduced
the leak and native Windows/Linux tests verified the correction in316dc55.
- Carried-value property browsing follows one exact record scope at a time, with
bounded pages and a separate read-only browser location. Flattened representative
paths were proven ambiguous in native recursive/reused data. Browsing does not
change the selected field or expression; choosing a field remains an explicit
draft edit through the ordinary validated command. No persistent browser schema.
+1 -1
View File
@@ -4,7 +4,7 @@
Deliver the first release of Arcadia Studio for system engineers: complete Operational Analysis and System Analysis/System Architecture workflows, equivalent graphical and textual models, clear artifact traceability and a deployable/programmatically accessible Rust service. Logical/Physical Architecture and later Arcadia layers are future releases, as the new objective explicitly states.
The authoritative objective is attachment07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md. It retains first-release scope and source/include, drag-selection and direct behavior editing requirements, and strengthens domain coherence, alternative evaluation and target-user task evidence. TASK_LEDGER.md separates given/verified facts, assumptions and open candidates; STATE.md records progress. The accepted0.2.0 prototype is the baseline, not proof of release completeness. Its source is preserved at D:/CodexBuilds/Capella2-release1-01a06e2e/v2-source-baseline.zip and existing archives remain in dist until replacement passes acceptance.
The authoritative objective is attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md. It retains first-release scope and source/include, drag-selection and direct behavior editing requirements, and strengthens domain coherence, alternative evaluation and target-user task evidence. TASK_LEDGER.md separates given/verified facts, assumptions and open candidates; STATE.md records progress. The accepted0.2.0 prototype is the baseline, not proof of release completeness. Its source is preserved at D:/CodexBuilds/Capella2-release1-01a06e2e/v2-source-baseline.zip and existing archives remain in dist until replacement passes acceptance.
## Existing architecture and retained decisions
+80 -36
View File
@@ -2,17 +2,36 @@
## Objective and phase
Direct scenario work is now in isolated `codex/scenario-direct`, checkout
`D:/CodexBuilds/Capella2-scenario-direct`, base ab661f2, dedicated target
`D:/CodexBuilds/Capella2-scenario-target`. Scope and acceptance are recorded in
release-scenario-editing.md. Existing scene/order semantics and typed drafts are
retained; direct drawing, explicit insertion, same-parent reorder and correlated
activation rendering are implemented in d1d8f0b, with independent-review fixes
in 7a66607 for stale scroll slots, actual Iced scroll coordinates, operand-specific
activation segments and repeated picker cost, plus e7e37d4 for passive scroll
observations during async save. DATA b35dede is integrated in dfd2e41 without
conflicts. Combined Windows desktop tests pass 286/286, strict all-target desktop
Clippy and optimized build pass; a copied artifact/manifest is frozen under
`D:/CodexBuilds/Capella2-release1-01a06e2e/ui-scenario-data-windows-dfd2e41`. Exact artifact
hash, before/after failures and timings are in release-scenario-editing.md.
Focused independent recheck reports no remaining medium/high finding. Native
Windows/Linux trials remain pending and root-owned; this branch claims no native
acceptance and does not close process/chain work.
The first-release goal remains active and incomplete. Deliver usable Rust/Iced
Operational Analysis and System Analysis/System Architecture, equivalent graphical,
textual and REST semantics, artifact traceability, directly editable behavior,
offline guidance, and Windows/Linux release evidence. Later Arcadia layers remain
future releases. The full authoritative objective was reread on 2026-09-06:
attachment07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md.
attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md.
PLAN.md retains the entire release scope; no subsystem acceptance completes it.
Direct state-machine editing is accepted through production316dc55. The main
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts are now
being implemented in a separate checkout; they are not accepted or released.
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts
through b35dede are now integrated into the isolated direct-scenario checkout.
Root retains final main integration and acceptance; the combined direct scenario
has not yet completed native trials.
Meaningful implementation and runtime progress continues; no external blocker exists.
## Accepted foundations
@@ -76,44 +95,57 @@ Dedicated target D:/CodexBuilds/Capella2-data-target. Contract freeze is schema/
with full data payload and dictionary slices together; existing typed ports remain.
See release-data-implementation.md and release-data-contract.md in that checkout.
Core is committed0b58107; source/migration1230ab8. Root API/docs are integrated
on the same working tree; desktop remains in flight and R6 is not accepted.
Core15 R6 +28workflow tests passed (final allocation-arrow suppression arrived
afterward and is queued for rerun). Earlier13ports/14scenarios and115 source/lib
tests passed; strict core Clippy preceded the last geometry/performance fixes.
R6 core/source/desktop are frozen at ab661f2. Full Windows607 tests and Linux610
tests pass (one explicit benchmark ignored on each platform); strict workspace
all-target Clippy passes on both. Optimized desktop/server Linux build also passed.
Windows desktop261 tests include the bounded scenario value editor. Exact logs,
binaries and runtime evidence: release-data-verification.md.
Root41 server tests pass, including3 new actual HTTP workflows for the published
data example, protocol/artifact identity, atomic rejected defaults, query/export,
reidentify/delete/restart, source3 bundle migration reports and old coupled3
envelope upgrade without write/revision. Tests exposed dropped bundle reports and
rejected legitimate old source/model pairs; both root fixes passed retest.
Strict reST HTML generation and81 OpenAPI schema definitions passed. Actual frozen
server32033FFE imported the published example; its complete Project/StoredProject
responses passed served/current JSON Schema validation and obsolete shapes rejected.
Evidence: api-r6-contract and r6-server-tests-after.log under the release directory.
All4 R6 actual HTTP workflows pass, covering data protocols/artifact identity,
rejected defaults, qualified Call/Reply paths, validation locations, source edits,
reidentification, source3 migration and restart persistence. The published example's
actual server responses passed served/local OpenAPI validation; obsolete shapes
reject. Strict reST rendering and81 OpenAPI schemas passed.
Independent core review found association transition closure pulling unrelated
types and missing empty-enum warning; both corrected and focused tests passed.
Repeated per-ancestor full scans were simplified to one scan per helper call.
Explicit debug inheritance timings250/500/1000:147/664/2739ms. Optimized timing is
still required; no optimized performance claim yet.
Explicit optimized inheritance timings250/500/1000: Windows21.070/89.088/391.590ms,
Linux22.381/111.662/408.129ms. These single-run empty-chain fixtures measure core
validation, not general UI latency. A1000-deep chain remains a pathological cost.
Current ownership: drawing_correctness owns desktop except data_view.rs;
state_architecture_review now owns only data_view.rs bounded focused-dictionary
correction. Its independent review found unbounded inherited rows/picklists,
unreachable recursive paths and loss of scenario values on same-phase style or
same-exchange reselection. Drawing worker fixes recursive drill-down/draft retention.
data_core now verifies Linux in a separate data-linux-review checkout/target.
Root owns API/OpenAPI/reST, integration and native input. No concurrent file writers.
Independent desktop review fixes now pass tests: a focused dictionary with24-row
pages replaces unbounded inherited rows; recursive paths support explicit drill-down
and imported finite paths; same-phase styles/same-exchange reselection preserve
valid drafts. Read-only follow-up found no new path/style defect. Paging resets
only on actual layer/document changes; its regression failed before and passes now.
The bounded scenario editor replaces whole-recursion enumeration and per-field
inputs with24-row browsing and a single focused value draft. The reply call picker
no longer filters alternative calls using values belonging to the old exchange.
Automated and independent checks pass; native scenario checks are next.
Root exclusively operates native R6. The Windows13-object data model has saved
native bounds/default repair, a typed physical flow/field, allocation protocol
edit, allocation removal/Undo, and inherited-owner navigation. Association endpoint
labels/selectability were exercised. The1100x700 data layout has been inspected.
A separate53-object recursive-operation fixture was prepared with core APIs and
round-tripped for upcoming native scenario trials; fixture construction is not
claimed as native authoring. See the verification note for remaining gates.
Parallel isolated work: drawing_correctness implements direct scenario gestures in
Capella2-scenario-direct. data_core owns typed semantic review core and
state_architecture_review owns source5/migration in Capella2-semantic-review.
The typed facet C design won the bounded experiment/criticism round; its production
plan and decision are recorded in that checkout. No overlapping writers or targets.
1. State evidence is committed and main fast-forwarded to a8f451e; DATA received
the same evidence in c5276b1. Finish the3 concrete desktop review fixes and
optimized inheritance measurement, then native data-authoring acceptance.
the same evidence in c5276b1. Finish native data-authoring acceptance and bounded
scenario-value authoring, then full integrated Windows/Linux verification.
2. Finish R6 core/source/desktop, integrate actual REST/schema/reST, independent
semantic/correctness/UX review, Windows/Linux checks and real data authoring.
3. Implement R3 typed semantic drift/reconciliation after R6 freezes. Current
baselines/reconciliation only cover prose. Prefer historical typed-body snapshots
and finite proposals; avoid a duplicate recursive semantic schema without evidence.
baselines/reconciliation only cover prose. Use the experimentally selected typed facet review design C; the semantic
integration checkout records the full scope and source5 migration plan.
4. Complete direct scenario/process gestures, persistent coverage/filters,
Home/recent/recovery, hosted desktop ETag workflows and offline guidance.
5. Fresh final requirements/domain/correctness/native UX/architecture review; full
@@ -121,12 +153,13 @@ Root owns API/OpenAPI/reST, integration and native input. No concurrent file wri
## Environment and native ownership
Root exclusively owns native input. Latest saved Windows trial is window1706782,
ui-state-navigation-windows,49objects/28links/1 expected missing-owner warning.
Linux navigation trial3148598 closed normally, exec46107 exit0; baseline Linux
trial721472 also closed normally, exec72680 exit0. Independent Windows3672536 is
saved at1100x700. Older root drawing windows are saved. Re-inventory before input.
Protected user7210312/6228978 were absent; do not reset/save/close if they return.
The user explicitly reauthorized native testing after Escape. Root's f5467e5 R6
window3476204 and exec93768 closed normally after verified source migration and
55-object recursive scenario reopen; no runtime output. The property-browser
replacement build b35dede has passed the root's Windows native retest. The
combined direct-scenario binary is frozen for its separate pending native trial.
Original Capella is present. Older protected user7210312/6228978 were absent;
do not reset/save/close them if they return. Re-inventory before switching apps.
Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e. State source checkout/target:
D:/CodexBuilds/Capella2-state-integration and -target. Linux source/target:
@@ -137,3 +170,14 @@ Windows: cargo test --locked --workspace; cargo clippy --locked --workspace
--all-targets -- -D warnings; cargo build --locked -p oa-desktop -p oa-server.
WSL Ubuntu24.04 uses target/linux-cargo.toml and Mesa software Vulkan for native
WSLg. No remote push or usage reset authorized. No broad recursive target deletion.
Latest R6 checkpoint: b35dede Windows264 desktop tests, strict Clippy, optimized
build and root-owned native retest pass for the one-record property browser. Paging and
location changes retain accepted model and typed expressions. f5467e5 Windows
and Linux263 desktop tests, strict Clippy and optimized release passed. Native
Windows source3 migration preserved comments/nesting through explicit Save All
and clean source4 reopen. Native recursive55-object scenario values and paired
Call/Reply history were verified; flat property paths prompted the latest fix.
The independent bounded-browser review reports no remaining medium/high finding.
Linux native R6 workflows remain a root-owned acceptance gate. See
release-data-verification.md. Main remains a8f451e; R6 is not yet accepted.
+13 -3
View File
@@ -1,6 +1,6 @@
# Task Ledger — first release
Authoritative objective: attachment07aee762-7d27-4cbe-81c3-2e82c7170711/
Authoritative objective: attachment10134df9-bea2-4c02-a820-67b53d6b86f7/
goal-objective.md, read fully2026-09-06. STATE.md records execution; PLAN.md retains
scope; DECISIONS.md and linked contracts preserve rationale.
@@ -39,6 +39,13 @@ requires native workflows, semantic evaluators and fresh independent review.
whole-model equality or flat reformatting. Independent counterexamples were fixed.
- R3 existing-target reconciliation remains name/description only. R6 data shape
expansion is active implementation, not accepted functionality.
- R6 ab661f2 full suites passed Windows607/Linux610; f5467e5 desktop263 and
strict Clippy/release passed on both platforms. Native Windows data editing,
typed defaults, allocations, recursive qualified Call/Reply values, history and
saved reopening passed. Source3 migration now preserves comments and nesting.
Native found flat property browsing still ambiguous; one-record browsing with
bounded pages now passes264 desktop tests/Clippy, pending rebuilt native checks.
Linux native R6 acceptance remains outstanding.
## Assumptions and open questions
@@ -48,8 +55,11 @@ requires native workflows, semantic evaluators and fresh independent review.
gestures still need their own occurrence/order semantics and task verification.
- Routing offers a usable initial path plus manual pins; arbitrary dense diagrams
are not guaranteed crossing-free. Measured dense full solve119ms remains a limit.
- R3 typed historical bodies plus finite proposals appear simpler than a separate
recursive semantic-value schema. This candidate awaits R6 freeze and review.
- R3 selected design C uses finite self-tagged typed facets, separate membership
and order acknowledgements, and transient constructor groups. Source5 historical
references are implemented in the isolated semantic checkout. Independent review
found narrow-acknowledgement/canonical-agreement/rollup defects; the worker has
added regressions and fixes. Full catalog, GUI/API/spec and acceptance remain.
## Decisions and current strategy
+119
View File
@@ -0,0 +1,119 @@
# R6 data contracts — native verification in progress
R6 is not accepted. Main remains at a8f451e. The full first-release goal remains
active and incomplete. User explicitly reauthorized native testing after Escape.
## Frozen production and automated evidence
Data integration ab661f2: Windows607 and Linux610 workspace tests pass, one explicit
benchmark ignored each; strict workspace all-target Clippy passes on both.
Windows261 desktop tests include the focused bounded scenario editor; Linux release
desktop/server build passed. Logs under D:/CodexBuilds/Capella2-release1-01a06e2e:
r6-workspace-windows-ab661f2.log and r6-workspace-windows-clippy-ab661f2.log.
Linux logs in Capella2-data-linux-review/target/linux-verification:
workspace-ab661f2.log, workspace-clippy-ab661f2.log, release-ab661f2.log.
Windows frozen executable ui-r6-windows/arcadia-studio.exe SHA256
C3ABD9E550E1AD6824DFE273EB27A3ABB35C5C0DA97C819DCB0AB2586AF5450C.
Current native window1835476, process session37598. No output observed; runtime.log
has not been created by Tee-Object (do not claim an empty log file).
All4 actual R6 HTTP tests pass with strict server Clippy: published data example,
protocol/artifact identity, rejected defaults, qualified reused/inherited/recursive
Call/Reply paths, phase/path422 with unchanged ETag/model/source/disk, source edits,
hop/terminal/base reidentification, correlation and restart persistence. Source3
coupled/source bundle migrations report correctly and opening alone does not write.
Logs: r6-http-paths-final.log, r6-http-all-data.log, r6-http-paths-clippy.log.
Initial fixture errors (untyped Port and transaction-error envelope assumption)
were corrected before passing. Published example responses from server32033FFE
passed served/local OpenAPI validation; obsolete shapes rejected. Strict reST HTML
and81 OpenAPI schemas passed. One stale port compatibility paragraph was corrected.
Optimized empty inheritance chains250/500/1000: Windows21.070/89.088/391.590ms;
Linux22.381/111.662/408.129ms. Single-run core validation timings, not UI latency.
## Personally observed Windows native workflows
Loaded the isolated Engineering data source fixture. Its architecture view shows
nested fields, association role/multiplicity labels, self-reference navigation and
selectable endpoint labels. No selection-induced dirty state observed.
Voltage maximum48→12 with default24 rejects when navigating; entered12 survives,
accepted summary remains0..48/default24, and a located default error is shown.
Repair default→12 applies together; Undo restores both old values, Redo restores
both new values. Created Cooling water with Material/Flow and contained Mass flow
rate Real1..1, unit kg/s, explicit default0.0. Save All wrote an included sa/types.arcadia
file with matching typed values, leaving13objects/4links/1 expected missing-System
warning. Navigation allocation changed Synchronous→Asynchronous; Monitoring's
separate Asynchronous/Read allocation is preserved. Removing Monitoring allocation
keeps Navigate; Undo restores the allocation and original checks. Followed inherited
Latitude from Located asset to Coordinate. Saved all edits. Data layout inspected
at1440x900 and1100x700; no overlapping controls observed.
Separate ui-r6-scenario-windows/model/project.arcadia was constructed with the core
API from the saved native data, adding functional ports/exchange, correlated
Call/Reply, reused/inherited/recursive records and30 extra fields.53objects/5links
validate and source round-trip exactly. fixture-generator.rs retained as evidence;
temporary project example removed. Initial generator compile/load errors were
fixture-only and fixed using the bundle-aware loader; final build has no warnings.
This preparation is not native scenario authoring evidence.
## Remaining acceptance gates
Native scenario focused/path/phase/draft behavior and paging; actual saved reopen;
source3 migration diagnostics; Linux native data/scenario workflows. Closure of
material findings before R6 acceptance. Independent semantic/correctness/spec
reviews already drove fixes; final native observations may require further review.
The broader release still needs semantic reconciliation, direct scenario/process
editing, lifecycle/hosted/help work and fresh cross-disciplinary end-state review.
## Native findings and follow-up fixes (2026-09-06)
At1100x700, the53-object recursive fixture exposed32/33 nested fields in two pages.
Native Call+Reply creation stored distinct Origin/Latitude and Destination/Latitude
expressions despite their shared terminal UUID. One Undo removed the pair; Redo
restored it. Save All wrote55objects/5links; source confirms both paths and reply
correlation. Three expected checks concern the absent SA System and two unallocated
functions. The saved evidence is ui-r6-scenario-windows/model/project.arcadia.
A third-hop popup exposed a material UI defect: repeated long path prefixes hid
all terminal field names. Fix74989b6 shows the terminal name first, sorts choices,
and retains the full address as wrapping detail text.262 desktop tests, strict
Clippy and release build passed; native retest is pending. Intermediate fixed hash
E592165A6FFFAEC64B67FF0A33FB4E78571527EE3C8F2CC457666DA6C277D5CB.
Native source3 import showed8semantic defaults,2generated allocations,1unresolved
value address and left the original hash305F19D1B79F99F8D4CF627BA2E9CA6836BB7959CC0070227815937A699EAFEA unchanged.
Its old desktop Save All path requested a new file and preserved the original, but
canonical regeneration discarded comments/nesting in the upgraded copy. This is a
material desktop/source parity defect, not a core bundle migration defect. The fix
keeps the historical single-document adapter only for source1/2; source3+ follows
bundle migration and propagates its report into Model checks. A regression passes
for actual disk single-file and included-file open, no write until explicit save,
comments, lexical ownership, report, exact saved source and clean reopened state.
Full desktop/Clippy/build and native retest are in progress; do not accept R6 yet.
The old Windows native window1835476 closed normally; exec37598 exited0 with no
runtime output beyond a newline. Re-inventory before opening the rebuilt binary.
## Migration retest and bounded property browsing
f5467e5 passed263 desktop tests, strict all-target desktop Clippy and optimized
release on Windows/Linux. Frozen Windows SHA256
33D1B2B6709BBC734E265568C7F93CB86A506497DEF3F4531669E5ABF8B2F039.
Native fresh legacy.arcadia import preserved original bytes until Save All,
displayed8defaults/2allocations/1unresolved address, and retained comments and
nested fields in its text editor. Save All kept the same file and source4 wording;
reopen was clean15objects/3links/4checks without a migration report. Saved SHA256
A8C0A689FEA6CC352007ACB127DE4A45E05290830DE815385CFFBAB777255543.
Evidence: ui-r6-final-windows/migration/legacy.arcadia.
The55-object recursive scenario reopened with both qualified expressions intact.
Leaf-first labels fixed the hidden terminal names, but existing-value properties
still flattened every representative path, causing repeated indistinguishable
leaf names. The follow-up browses one record at a time, with24-choice pages and
wrapping current address/location text. Up/root/page actions change only browser
state; draft field and expression are preserved.264 desktop tests and strict
Clippy passed, including wide-record page coverage and draft preservation across
recursive browsing. Optimized build and native retest remain pending. Frozen
f5467e5 window3476204 closed normally; exec93768 had no runtime output.
+236
View File
@@ -0,0 +1,236 @@
# Direct scenario editing — bounded implementation plan
Status: implementation and automated Windows checks passed in the isolated
`codex/scenario-direct` checkout, based on ab661f2. Initial production is d1d8f0b;
independent-review fixes are in 7a66607 and e7e37d4. The focused independent
recheck reports no remaining medium/high finding. Root-owned Windows/Linux native
trials remain pending. DATA b35dede is integrated in dfd2e41 with 286 desktop
tests, strict Clippy and a fresh frozen Windows release artifact. This does not
complete the release or process/chain work.
## Decisions
Extend the existing `behavior::Scene`; retain its derived lifeline columns and
ordered occurrence rows. No model/schema/layout records are added. Lifeline and
occurrence UUIDs remain distinct from represented work and global exchanges.
Select mode selects headers, stems, messages and occurrence boxes. A selected
occurrence has a reorder handle; dragging it chooses a visible slot in its same
parent sequence. It never reparents or changes lifeline columns. A Message tool
uses the existing MessageStyle, with Call + correlated reply initially selected.
Click-click or drag between represented lifelines captures a highlighted explicit
insertion slot `(parent, before occurrence or append)` and opens the existing
typed draft with those endpoints. Exchanges are filtered by layer, viewpoint and
the core's represented-endpoint compatibility. Earlier calls retain complete
exact-slot validation, cached by document revision, owner, slot and endpoints.
Choices use search plus 24-item pages; every final creation still validates the
complete ordinary command at the captured insertion slot.
No compatible exchange produces an actionable retained draft, not a new global
exchange or a silently changed endpoint. Timer remains available as a local tool.
The canvas remains rendered while a bounded, independently scrollable form is
open. Values keep the existing qualified-path editor and lifecycle. Final creation
uses ordinary CreateElement plus SetSteps in one Batch; paired call/reply are
inserted together and undo together. Reordering uses SetSteps within the captured
parent. Existing core validation decides reply order, branch dominance, lifetime,
phase and endpoint compatibility. Failed preparation/source writes retain drafts.
Transient events capture document/scenario/revision/epoch, and the App validates
the current Behavior owner before handling them. Selection does not invalidate a
gesture within the same scenario. Escape, unfocus, context/tool change and pending
drafts cancel gestures. Scroll, bounds changes and actual window resize also
cancel the captured source/slot. Ordered raw pointer events are translated by the
actual combined inner/page Scrollable offsets. No geometry is persisted.
Execution bars derive their ends from actual correlated reply positions; separate
conditional reply branches render their corresponding spans. Calls without a
visible reply use a short explicitly open activation, not a fabricated reply.
## Acceptance checks
Production-event tests cover hit priority, click-click/drag, scroll-origin changes,
Escape/unfocus/stale contexts and exactly one result per gesture. Planner/App tests
cover middle/operand insertion, paired identities, complete SetSteps ordering,
same-parent reorder, rejected reply-before-call, unchanged global exchanges, exact
source/model Undo, draft retention and bounded exchange/call controls. Run focused
and full desktop tests, strict all-target desktop Clippy and optimized build with
the dedicated `D:/CodexBuilds/Capella2-scenario-target` target.
Root native tasks (not yet performed):
1. Draw an existing exchange again into the middle of a scenario as Call+Reply;
enter a qualified value, select both occurrences, Undo/Redo, save/reopen.
2. Insert inside an ALT/LOOP operand, reorder within it, reject a reply before its
call, and inspect correlated execution bars and preserved occurrence identities.
3. Cancel drawing after scrolling/layout change, retry, retain typed values while
navigation is blocked, and open/return from a referenced scenario. Verify at
1440x900 and 1100x700 on Windows and Linux before scoped acceptance.
## Initial automated evidence — 2026-09-06 (superseded by review fixes)
Source checkout: `D:/CodexBuilds/Capella2-scenario-direct`, production commit
`d1d8f0bbed5aa0112189138a1cc025affe26c09b`. Changes are confined to desktop
`app.rs`, `behavior.rs` and `behavior/scenario.rs`; no core model, persisted layout
or schema changes are included.
All commands used the dedicated `D:/CodexBuilds/Capella2-scenario-target` target
with `--locked --offline`:
- `cargo check -p oa-desktop --all-targets`: passed.
- `cargo test -p oa-desktop direct_`: 19 passed, including 13 new direct-scenario
tests and six existing matching tests.
- `cargo test -p oa-desktop`: final source passed all 274 tests, zero failures or
ignored tests. This includes exact source/model Undo, creation and reorder
selection, rejected stale contexts, retained failed-write drafts, bounded
contextual controls, scroll-origin cancellation and correlated activation spans.
- `cargo clippy -p oa-desktop --all-targets -- -D warnings`: passed.
- `cargo build -p oa-desktop --release`: passed.
- `git diff --check`: passed before the production commit.
Logs are under `D:/CodexBuilds/`: `Capella2-scenario-check.log`,
`Capella2-scenario-direct-tests.log`, `Capella2-scenario-desktop-final-tests.log`,
`Capella2-scenario-desktop-clippy.log` and `Capella2-scenario-desktop-release.log`.
Windows artifact:
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
20,345,856 bytes, SHA-256
`7CC6DCDB7ECC0C6B144137D31DB0BE9BC1555FEFAE6B465DAEABCF52DFE1A166`.
This artifact has not been native-tested. Widget-tree/event tests do not establish
visual usability, scrolling behavior or GPU rendering acceptance; the three native
tasks above remain required. Process/chain direct editing remains outside this
bounded implementation.
## Independent-review fixes and final automated evidence — 2026-09-06
Production commit: `7a666076bfcfd1f2258d2f5508328db5adf40825`, same isolated
checkout and dedicated target. Only the three previously owned desktop source
files changed. No core/source/schema or process/chain features were added.
The new event regressions failed on the prior implementation: a bounds shift kept
the first click's source/slot armed, and a held drag could still publish its old
slot after the bounds changed. Both now cancel. After scrolling, resizing or
clipping changes, the next click starts a fresh gesture. App tests also verify
unchanged-size notifications retain context, actual resize invalidates it, and
scrolling preserves property/source history and the pending behavior form.
Local Iced inspection established that Scrollable translates its supplied cursor
but leaves raw CursorMoved coordinates and the canvas layout unchanged. The
existing inner and outer scrollables now have a local, passive observer which
reads their actual translation through Widget::operate. Ordinary on_scroll cannot
report initial/remounted state or passive content-size clamping. The observer
forwards layout, drawing, overlays and event capture, and publishes only changed
translations or document/owner context. Reporting before child input makes a
result using an old slot stale before App processes it; reporting after input
captures wheel/scrollbar changes. The real Iced Scrollable test uses its debug
null renderer and verifies 0 → 180 wheel offset → 50 passive clamp → 180 on
expansion → 0 after remount, with no repeated messages on unchanged redraws.
The reported ALT fixture also failed before the fix: a root Call, Reply then
Action in operand one, another Reply in operand two, and a third operand without
a Reply. Derived execution geometry now splits at operand boundaries, stops each
returned operand at its own correlated Reply, leaves its following Action outside
that segment, and marks the reply-free operand open. It does not imply chronology
between alternative operands or add persistent execution records.
Picker measurement on the same Windows debug target:
| Probe | Before | After |
| --- | --- | --- |
| 500 compatible exchange choices | 7.87 s | 1.46 ms |
| Same choices after one name edit | 8.14 s | 1.48 ms |
| 100 exact-slot earlier calls, initial validation | not measured | 595 ms |
| Those calls after name/value/search/page edits | not measured | 34–39 µs |
The exchange list no longer clones and validates the entire project per candidate.
Final Apply still rejects a Delete inserted before a later Call on that lifeline;
the new regression confirms appending that same Delete succeeds. Reply choices
retain exact validation, invalidate for changed slot/document/revision/endpoints,
and do not lose their cached eligibility while expressions are being typed.
These are bounded picker measurements, not complete native-frame timings.
Final checks (`--locked --offline`): 27 focused matching tests passed; all 282
desktop tests passed, zero failures/ignored tests; strict desktop all-target
Clippy passed; optimized desktop build passed; git diff --check passed. Clippy
requested one test-only nested-if cleanup, which was applied before the final
clean Clippy/build.
Logs under `D:/CodexBuilds/`:
`Capella2-scenario-review-before-tests.log`, `Capella2-scenario-alt-before.log`,
`Capella2-scenario-scroll-observer-tests.log`,
`Capella2-scenario-review-desktop-tests.log`,
`Capella2-scenario-review-clippy.log`, `Capella2-scenario-review-release.log`.
Current Windows binary:
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
20,349,952 bytes, SHA-256
`1AE729097F267DE596AE0970D3A62AE06C0C89CE2FDE70DF980AD785A0E0A8E6`.
It supersedes the initial artifact above. Native acceptance is still pending.
Root should repeat the three primary native tasks, including inner/outer wheel
and scrollbar cancellation at a middle slot, resize/remount/clamp recovery, and
the three-operand ALT fixture. The broader release objective remains incomplete.
## Final passive-scroll save correction — 2026-09-06
Production `e7e37d485d842731d270781bd4e60a323fe4cdba` addresses the focused review's remaining App lifecycle
finding. During an async save, Iced can change its scroll position while App's busy
gate rejects ordinary messages. The observer had already recorded its report,
so dropping that notice left App's offset stale after save completion. The new
regression failed before the fix: App retained offset (0, 0) instead of (25, 140).
App now dispatches only the passive Scrolled event before interaction gates. Its
existing handler still validates document, scenario and revision. Mutation input
remains gated. The test checks current offsets and cancellation epoch while busy,
rejects an old-document observation, delivers a successful Saved receipt, then
uses production Canvas pointer events to create the intended sender/receiver/slot
draft without a model or source-history mutation.
The independent reviewer rechecked this exact production change and regression;
no remaining medium/high finding was reported in the focused review. Final full
desktop tests pass 283/283, strict all-target desktop Clippy passes, optimized
desktop build passes, and git diff --check passes. Logs under D:/CodexBuilds are
`Capella2-scenario-busy-scroll-before.log`,
`Capella2-scenario-busy-scroll-after.log`,
`Capella2-scenario-busy-scroll-desktop-tests.log`,
`Capella2-scenario-busy-scroll-clippy.log` and
`Capella2-scenario-busy-scroll-release.log`.
Latest Windows artifact, superseding both builds above:
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
20,349,952 bytes, SHA-256 `CEEB8E277DFC47C0D55DA43F903FCA45AFBF902AFA418B3924375910D699373A`.
Native acceptance is pending. Include scrolling while saving in the root's native
recovery task before accepting the direct scenario milestone.
## Combined DATA/scenario integration — 2026-09-06
Merge `dfd2e41259e73ef10a4e29a426569c08eaae215f` integrates `codex/data-contract-integration` at
`b35dede75fc8778ea695b9b2c023cfa2a1e49e09` into the isolated scenario branch.
The merge applied without conflicts and needed no manual code resolutions or new
semantic design. Direct scenario geometry/planners/tests and the existing
scenario evidence were unchanged by the merge. App's early passive-scroll handler
remains intact alongside DATA's source migration and property-browser handling.
The shared field-path API remains compatible: compact leaf-first labels and
deterministic sorting now feed the existing bounded scenario value picker, while
full qualified labels remain available for detail text.
Combined verification on the dedicated scenario target: all 286 desktop tests
passed, no failures or ignored tests; strict desktop all-target Clippy passed;
optimized desktop build passed; staged diff --check passed. All commands used
`--locked --offline`. DATA's three new regressions are present together with the
283-test scenario suite, including source3 save/reopen and direct gesture/save
lifecycle coverage. No DATA checkout, Linux checkout or native application was
modified or operated in this integration task.
Frozen artifact directory: `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-scenario-data-windows-dfd2e41`.
It contains `arcadia-studio.exe`, the three combined verification logs and a
`manifest.json` identifying the source checkout, merge commit, target and hash.
The copied binary hash was checked against the release target binary.
Binary size: 20,411,392 bytes. SHA-256: `8BC031CF6124859D31082BB0FFB0335C406D54E12ECAD8493CE50A23923E5589`.
Root reports DATA b35dede's Windows native trial and independent focused review
passed. This does not transfer native acceptance to the combined direct-scenario
workflow. Its Windows/Linux native tasks remain pending, including scroll/resize
cancellation, middle/operand insertion, execution spans and save recovery. Root
owns final integration into main and native acceptance; process editing remains
outside this task.