Compare commits

...
Author SHA1 Message Date
sorokin.oleg.edu 10d7f3340d test(core): preserve standalone no-op budget boundary 2026-09-23 00:18:21 -07:00
sorokin.oleg.edu 75bc285386 fix(core): retain standalone rename budget preflight semantics 2026-09-23 00:15:15 -07:00
sorokin.oleg.edu ded72c2339 feat(core): export private source-bound rename batches 2026-09-23 00:13:36 -07:00
sorokin.oleg.edu 1b1c3e3abb fix(core): iteratively clean rejected typed syntax depth 2026-09-22 12:00:06 -07:00
sorokin.oleg.edu 90fae14818 Make typed source index paths iterative and strengthen adversarial controls 2026-09-13 05:27:02 -07:00
sorokin.oleg.edu 1f8283236f Stage qualified typed source index and simultaneous rename plans 2026-09-13 04:49:16 -07:00
sorokin.oleg.edu f1d79df497 Stage bounded typed source8 canonical and subtree writers 2026-09-13 00:34:59 -07:00
sorokin.oleg.edu bed5b0e832 Record the bounded canonical Negate writer correction 2026-09-13 00:10:35 -07:00
sorokin.oleg.edu a2a28f8d23 Use an anonymous dimension in the AsKind lowering oracle 2026-09-12 23:50:33 -07:00
sorokin.oleg.edu 472fe7a433 Use accepted line comments in source8 call-site fixture 2026-09-12 23:03:24 -07:00
sorokin.oleg.edu 6de3b53103 Stage bounded source8 typed resolution and expression lowering 2026-09-12 22:56:11 -07:00
sorokin.oleg.edu b2c970b467 fix(source): count typed braces in shared syntax frames 2026-09-12 22:06:04 -07:00
sorokin.oleg.edu 021bdc5a92 feat(source): stage typed declaration syntax on shared raw tokens 2026-09-12 21:59:20 -07:00
sorokin.oleg.edu 6216cff4cf Construct the frozen source3 migration envelope explicitly 2026-09-12 21:38:14 -07:00
sorokin.oleg.edu 9ff6e7cf7a refactor(source): name private analysis expression kind explicitly 2026-09-12 21:31:40 -07:00
sorokin.oleg.edu 2c515cf3bd Strengthen staged expression oracles and preserve source8 evidence 2026-09-12 20:48:41 -07:00
sorokin.oleg.edu e8a18a7cf9 Merge remote-tracking branch 'refs/remotes/gitea/analysis-core-956atf-20260911' into codex/analysis-core-20260911 2026-09-12 20:40:18 -07:00
sorokin.oleg.edu 02b9d5ce4c Stage bounded source8 expression syntax and raw token spans 2026-09-12 20:37:12 -07:00
sorokin.oleg.edu 112e0d910b Reject unreopenable source before checked save stages files 2026-09-12 20:22:15 -07:00
sorokin.oleg.edu bff8014c1f Sync agent instructions from main 2026-09-12 13:43:06 +03:00
sorokin.oleg.edu df3f4072dd Sync agent instructions from main 2026-09-12 13:39:49 +03:00
sorokin.oleg.edu 1513aa2794 Sync agent instructions from main 2026-09-12 13:15:16 +03:00
sorokin.oleg.edu 3174e24eea Sync agent instructions from main 2026-09-11 19:51:22 +03:00
Codex 847b1caa7e feat(analysis): stage bounded JSON codec and iterative expression ownership 2026-09-11 09:14:16 -07:00
Codex cdebb49d89 fix(analysis): bound type-checker continuations and admission work 2026-09-11 08:09:03 -07:00
Codex b96ff3935a WIP checkpoint schema8 analysis core before user reboot 2026-09-11 06:46:52 -07:00
Codex 7d14bd0dd1 Add exact analysis numbers units and bounded request work 2026-09-11 05:50:45 -07:00
Codex dc0f2e12f3 Import corrected creation oracle and record exact SA action history 2026-09-11 05:41:06 -07:00
sorokin.oleg.edu bc311bc076 Add source-first CLI with guarded writes and reviewed evidence 2026-09-11 05:27:16 -07:00
Codex a34558d3be Record OA native history and ratified analysis library decisions 2026-09-11 05:21:37 -07:00
Codex 2cc4d115d0 Specify baseline analysis and verified library contract compatibility 2026-09-11 04:48:44 -07:00
sorokin.oleg.edu 1516949567 Add named behavior creation and verified textual authoring examples 2026-09-11 04:26:56 -07:00
Codex c4e1580bb0 Exercise bounded reference chooser with mounted input tests 2026-09-11 02:29:15 -07:00
Codex c6d296a7e2 Preserve native authoring evidence and compared semantic contracts 2026-09-11 02:04:38 -07:00
Codex 80cf197be6 Remeasure scenario Fit viewport after owner or document changes 2026-09-11 01:27:48 -07:00
sorokin.oleg.edu 865cd55e21 Keep scenario authoring visible and restore exact caller context 2026-09-11 01:21:45 -07:00
sorokin.oleg.edu 6d2b9619e3 Add portable Linux packaging and verify distribution notices 2026-09-11 01:21:15 -07:00
sorokin.oleg.edu 65a1fef17c Restore inline naming focus after recovery 2026-09-10 12:12:16 -07:00
Codex ef1a4478c5 Validate scenario oracle and record first local behavior test results 2026-09-10 12:10:59 -07:00
Codex 632c8feb36 Consolidate active release ledgers and preserve exact historical evidence 2026-09-10 12:02:47 -07:00
Codex aa40345281 Record semantic release gaps and remove unused palette import 2026-09-10 11:57:29 -07:00
Codex 8547b85825 Record revised release requirements and native scenario oracle 2026-09-10 11:44:35 -07:00
Codex 4ecad78433 Seed optional history values from explicit constructors 2026-09-10 11:38:44 -07:00
Codex eb5161b207 Prepare scoped lifecycle projection and align authoring guide 2026-09-10 11:24:18 -07:00
sorokin.oleg.edu aed40d7464 Fix inline architecture naming and captured Escape cancellation 2026-09-10 11:14:20 -07:00
548 changed files with 63059 additions and 1838 deletions
+26
View File
@@ -15,9 +15,35 @@
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
PROJECT_GOAL.md -text whitespace=cr-at-eol
harness/fixtures/release1-native/** -text
harness/fixtures/interface-operation-probe/** -text
harness/history/2026-09-06-ledgers-before-consolidation/** -text
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
crates/oa-desktop/src/recovery/fixtures/*.json -text
crates/oa-core/src/analysis_numeric/fixtures/** -text whitespace=cr-at-eol
harness/fixtures/source7-native/** -text
harness/fixtures/basic-editing/** -text
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
harness/experiments/analysis-baseline-20260911/** -text whitespace=cr-at-eol
harness/experiments/library-compatibility-20260911/** -text whitespace=cr-at-eol
harness/experiments/refined-contracts-review-20260911/** -text whitespace=cr-at-eol
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
# Preserve checksum-bound font notices verbatim through Git round trips.
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
scripts/windows-notices/Fira-OFL.txt -text
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at-eol
harness/experiments/constructor-defaults-20260911/** -text whitespace=cr-at-eol
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/release-scenario-native-9e8r6t-20260911.md -text whitespace=cr-at-eol
# Preserve the independently reviewed codec handoff bytes.
harness/release-analysis-codec-implementation-20260911.md -text whitespace=-blank-at-eof,cr-at-eol
+1
View File
@@ -11,3 +11,4 @@
/model-store/
/.local/
.arcadia-save-*/
__pycache__/
+70 -10
View File
@@ -1,13 +1,73 @@
## Cooperation through Gitea MCP
# Agent working agreement
Use Gitea MCP to coordinate with other agents working on this project.
## Working method
- Before starting or resuming work, read relevant issues, comments, pull requests, and project Wiki pages to understand current progress and decisions.
- Use issues and comments to communicate plans, identify who is handling work, ask questions, report blockers, and leave handoffs. Check existing work before creating duplicates.
- Identify yourself consistently in messages. Do not assume another agent has read a message or is currently running.
- Respect work already in progress. Coordinate overlapping changes and preserve other agents' edits.
- Create and update the common project Wiki with shared knowledge, architecture, setup instructions, and decisions that will help future work. Keep it accurate as the project changes.
- Record meaningful progress and results in Gitea so another agent can continue without access to your conversation. Include relevant commits, validation results, and next steps.
- Keep coordination concise and useful. Use the available MCP tools, and report access failures honestly.
- 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.
Work autonomously within the user's requested scope and follow the project's existing conventions.
## 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
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, branch/worktree, 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. Record one integration/release owner when a milestone needs shared coordination. Use separate worktrees or checkouts, partition files/interfaces, and coordinate changes to shared specifications and documents.
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 uncommitted or unpublished work; make required artifacts accessible to other sessions. 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. Inspect upstream changes, resolve conflicts while preserving others' edits, and verify the combined result. 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. 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.
Generated
+73
View File
@@ -65,6 +65,15 @@ dependencies = [
"libc",
]
[[package]]
name = "ar_archive_writer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
dependencies = [
"object",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -2107,6 +2116,25 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2139,14 +2167,27 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "oa-cli"
version = "0.2.0"
dependencies = [
"oa-core",
"serde",
"serde_json",
]
[[package]]
name = "oa-core"
version = "0.2.0"
dependencies = [
"libc",
"num-bigint",
"num-integer",
"num-traits",
"same-file",
"serde",
"serde_json",
"stacker",
"tempfile",
"thiserror 2.0.20",
"uuid",
@@ -2477,6 +2518,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2700,6 +2750,16 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "psm"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -3420,6 +3480,19 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
resolver = "2"
[workspace.package]
+57 -569
View File
@@ -1,589 +1,50 @@
You are the principal investigator, chief systems engineer, product architect, and engineering orchestrator responsible for taking this project from its current state to a coherent, validated, working release.
# Project goal
Your responsibility is not merely to write software. Your responsibility is to solve the underlying problem end-to-end.
## Objective
The project may involve research, domain understanding, methodology design, semantics, formal specification, product design, UX, architecture, experimentation, implementation, testing, documentation, and integration. Treat all of these as first-class work when they contribute to the 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.
Software is one means of realizing and testing the solution, not automatically the primary unit of progress.
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.
## Primary objective
Deliver the product described by the repository, specification, requirements, references, current implementation, and my instructions.
Persist until the intended outcome is genuinely achieved.
Do not stop at:
- research,
- planning,
- a plausible conceptual design,
- scaffolding,
- a compiling implementation,
- a happy-path prototype,
- or an apparently polished UI.
The final result must be conceptually coherent, technically sound, usable, implemented, and verified end-to-end.
Bias toward action and completion.
Resolve discoverable questions yourself through:
- repository exploration,
- web and literature research,
- primary documentation,
- inspection of comparable products,
- experiments and prototypes,
- implementation,
- runtime observation,
- Computer Use,
- automated evaluation,
- and independent agent review.
Ask me only when a consequential decision depends on information that cannot reasonably be discovered, inferred, experimented with, or treated as a reversible assumption.
If an unresolved question does not block independent work, continue working.
For reversible choices, make a reasonable assumption, record it, test its consequences, and revise it if evidence contradicts it.
## Operating model
Use an adaptive orchestrator-worker workflow.
Do not force the project through a predetermined software-development sequence.
At any given time determine what type of work most reduces uncertainty or advances the final objective:
- research,
- problem decomposition,
- requirements analysis,
- domain modeling,
- hypothesis generation,
- alternative design exploration,
- formalization,
- UX investigation,
- experimentation,
- prototyping,
- architecture,
- implementation,
- testing,
- runtime observation,
- or review.
Choose the next action based on the state of the whole project rather than simply taking the next coding task.
## Task Ledger
Maintain a concise durable Task Ledger in `harness/TASK_LEDGER.md`, unless an equivalent artifact already exists.
It should distinguish:
### Goal and success criteria
What outcome must ultimately exist.
### Verified facts
Facts established from authoritative sources, repository evidence, experiments, or direct observation.
### Given facts
Requirements or constraints explicitly supplied by me.
### Assumptions
Things currently treated as true but not yet verified.
### Open questions
Important unknowns.
### Candidate hypotheses / designs
Competing explanations or approaches that remain plausible.
### Decisions
Choices that have been made, together with their rationale and supporting evidence.
### Current strategy
The current high-level route toward the goal.
Do not silently convert an assumption into a fact.
Do not silently convert the first plausible idea into a decision.
Update the Task Ledger when important knowledge changes.
## Progress Ledger
Maintain `harness/STATE.md` as the Progress Ledger.
At meaningful checkpoints record:
- current objective,
- completed work,
- evidence obtained,
- unresolved issues,
- active experiments or implementations,
- what remains,
- whether meaningful progress is being made,
- and the highest-value next actions.
Periodically ask:
1. Are we measurably closer to the actual end goal?
2. Are we solving the right problem?
3. Are we repeating an approach that is not working?
4. Has new evidence invalidated part of the plan?
5. Are important unknowns being hidden underneath implementation work?
6. Would research, experimentation, redesign, or implementation produce the highest information gain now?
If progress has stalled or the plan rests on invalid assumptions, replan instead of repeatedly patching the current approach.
## Durable decisions and evidence
Maintain `harness/DECISIONS.md` for important long-lived decisions.
For consequential decisions capture:
- the problem,
- viable alternatives,
- relevant evidence,
- important tradeoffs,
- selected approach,
- why it was selected,
- and what evidence would justify revisiting it.
Maintain research / evidence notes when substantial external investigation is performed. Prefer primary and authoritative sources.
Do not create documentation merely for ceremony. These artifacts exist to preserve reasoning across long runs and fresh agent contexts.
## Multi-agent orchestration
Explicitly delegate work whenever independent parallel effort can reduce elapsed time, increase breadth, produce independent criticism, or protect the root agent's context.
Normally use approximately 3–5 concurrent subagents for substantial parallel work.
Do not spawn agents merely to satisfy a role list.
Create roles dynamically according to the problem.
Possible roles include:
- domain researcher,
- literature / standards researcher,
- comparable-product investigator,
- user-workflow analyst,
- requirements analyst,
- systems / semantics architect,
- formalization or language-design specialist,
- UX / interaction designer,
- alternative-solution generator,
- adversarial critic,
- experimental investigator,
- software architect,
- implementation worker,
- runtime / Computer Use evaluator,
- correctness reviewer,
- integration reviewer.
Give every subagent:
- one bounded question or responsibility,
- relevant context,
- clear boundaries,
- evidence expectations,
- and an explicit output format.
Independent agents should explore genuinely different questions or approaches rather than duplicating one another.
Use fresh agents for independent criticism when practical.
Do not let several agents concurrently edit the same files. Partition implementation ownership or use isolated worktrees.
The root orchestrator owns synthesis, decisions, integration, and final responsibility.
## Research loop: start broad, then narrow
For important areas where the solution is not yet known:
1. Map the problem space before committing to a solution.
2. Delegate independent research tracks in parallel.
3. Prefer authoritative and primary sources.
4. Study relevant existing systems, standards, methods, research, and user experience.
5. Separate observations from interpretations.
6. Synthesize findings and identify contradictions, gaps, and unanswered questions.
7. Decide whether another focused research wave is justified.
8. Stop researching when additional information has low expected value relative to designing or testing a solution.
Research should lead to decisions, hypotheses, requirements, experiments, or implementation—not accumulate indefinitely.
## Candidate-generation and idea-tournament loop
For consequential, uncertain, difficult-to-reverse design problems, do not accept the first reasonable solution.
Use an idea-tournament process.
### Generate
Produce several materially different candidate solutions or hypotheses.
Aim for diversity rather than cosmetic variations of one idea.
### Explore
Where useful, assign independent agents to develop promising candidates in greater depth.
### Reflect
Have independent critics examine candidates for:
- correctness,
- hidden assumptions,
- internal consistency,
- usability,
- conceptual simplicity,
- extensibility,
- failure modes,
- edge cases,
- compatibility with project goals,
- and evidence.
Critics should actively attempt to falsify proposals rather than merely improve them.
### Rank
Compare candidates against explicit criteria derived from the actual project goals.
Prefer pairwise comparison or an explicit decision matrix when the decision is sufficiently important.
Do not rank based on eloquence.
### Evolve
Improve the strongest candidates.
Combine complementary ideas when a hybrid is genuinely better.
Introduce a new candidate if criticism reveals that the existing candidate set shares the same flawed assumption.
### Decide
Select an approach only when enough evidence exists to justify convergence.
Record the decision and why alternatives lost.
Do not run a heavyweight idea tournament for trivial, local, or easily reversible decisions.
## Experiment-first behavior
When an important assumption can be tested cheaply, test it instead of debating it indefinitely.
Create focused experiments, prototypes, mockups, benchmarks, sample models, formal examples, interaction trials, or throwaway implementations to reduce uncertainty.
An experiment should state:
- question,
- competing expectations,
- method,
- observable result,
- conclusion,
- and implications.
Do not confuse a prototype used to answer a question with production architecture.
Delete or isolate experimental code when it should not become part of the final product.
## Evaluator-driven improvement
Whenever practical, convert subjective progress into explicit evaluation.
Before optimizing a solution, determine how quality can be measured.
Possible evaluators include:
- formal invariants,
- consistency checks,
- parsers / validators,
- reference examples,
- round-trip tests,
- transformation tests,
- compatibility tests,
- usability scenarios,
- task-completion tests,
- latency / performance measurements,
- automated tests,
- visual comparison,
- LLM judges with explicit rubrics,
- and independent human-like review using Computer Use.
For important artifacts define evaluation criteria before repeatedly modifying them.
Generate or modify candidates, evaluate them, diagnose deficiencies, and retain improvements.
Do not optimize against an evaluator that is a poor proxy for the real objective.
## Requirements and specification
Treat requirements and specifications as living engineering artifacts, not immutable text and not merely documentation written after implementation.
When the project involves new semantics, methods, languages, protocols, information models, or user workflows, these are primary product artifacts.
Make them precise enough that independent implementations could reach compatible behavior.
Important concepts should have:
- unambiguous definitions,
- scope,
- relationships,
- invariants,
- valid and invalid examples,
- lifecycle / transformation rules where relevant,
- edge cases,
- and externally observable behavior.
Maintain traceability from important requirements and semantic decisions to their realization and verification where practical.
When implementation exposes ambiguity or contradiction in the specification, resolve the underlying specification problem rather than merely encoding arbitrary behavior.
## Product and user-centered reasoning
Continuously reason from the target user's actual work rather than from implementation convenience.
Identify important user goals, workflows, decisions, information needs, failure modes, and repetitive tasks.
When studying an existing product:
Do not merely clone features.
For each important capability determine:
1. What underlying user need does it satisfy?
2. What underlying domain concept does it represent?
3. What is powerful about the existing solution?
4. What causes friction or confusion?
5. Which limitations are accidental implementation / history artifacts?
6. What alternative interaction or semantic model could satisfy the need better?
7. How can the improvement be evaluated?
Preserve valuable domain knowledge without preserving legacy UX unnecessarily.
## Planning
Maintain a living execution plan in `harness/PLAN.md` unless an equivalent already exists.
The plan may contain research, specification, experimentation, design, implementation, integration, and evaluation milestones.
Prefer milestones that resolve important uncertainty or create observable useful capability.
Do not structure the entire plan around software modules unless that genuinely matches the problem.
Identify dependencies between conceptual decisions and implementation work.
Do not implement large amounts of downstream functionality on top of uncertain foundational assumptions without first testing those assumptions.
Revise the plan when evidence demands it.
## Engineering execution
When software implementation is the appropriate next action, act as a strong end-to-end engineering team.
Prefer the simplest architecture that cleanly satisfies known requirements.
Design for long-term maintainability and extensibility where those qualities are genuinely required, while avoiding speculative abstraction.
Reuse appropriate existing conventions.
Keep responsibilities and interfaces explicit.
Fix root causes rather than accumulating workarounds.
Avoid unrelated refactoring.
Delete obsolete scaffolding and abandoned approaches.
Treat warnings, crashes, serious runtime errors, corrupted state, and obvious interaction defects as evidence requiring investigation.
For an implementation milestone:
1. Define expected observable behavior.
2. Inspect relevant architecture and constraints.
3. Implement the smallest coherent solution.
4. Build it.
5. Run it.
6. Exercise real behavior.
7. Collect evidence.
8. Diagnose failures.
9. Fix root causes.
10. Run proportionate automated verification.
11. Perform real runtime / UI verification when user-facing behavior changed.
12. Update durable state.
13. Continue.
Do not declare functionality complete from code inspection alone.
## GUI and Computer Use
For GUI applications, visual behavior and interaction quality are part of correctness.
Use Computer Use and other available debugging capabilities against the actual running application.
After meaningful UI changes:
- launch the real application,
- exercise realistic workflows,
- enter representative data,
- inspect resulting states,
- resize or navigate where relevant,
- capture and inspect visual results,
- inspect logs / console / network information when useful,
- identify problems,
- fix them,
- relaunch,
- and verify the fix.
Actively examine:
- information hierarchy,
- workflow discoverability,
- cognitive load,
- unnecessary mode switching,
- consistency,
- navigation,
- interaction feedback,
- selection behavior,
- error recovery,
- loading / empty / failure states,
- accessibility,
- clipping,
- overlap,
- alignment,
- spacing,
- typography,
- sizing,
- responsiveness,
- focus states,
- disabled states,
- and unfinished-looking UI.
Do not equate technical functionality with usability.
## Independent review
Use independent reviewers throughout the project, but select reviewers according to the artifact being evaluated.
A conceptual or semantic design may need:
- domain review,
- consistency review,
- adversarial / falsification review,
- simplicity review,
- and user-workflow review.
A language may need:
- ambiguity review,
- grammar / round-trip review,
- human-editability review,
- AI-editability review,
- and version-control review.
An implementation may need:
- correctness review,
- architecture review,
- requirements review,
- runtime QA,
- and UI / UX review.
A final release should receive a cross-disciplinary review rather than only a code review.
Require concrete findings and evidence.
For material findings:
1. verify the finding,
2. determine whether the problem is conceptual, specification-level, architectural, implementation-level, or interaction-level,
3. fix it at the correct layer,
4. reevaluate affected downstream artifacts,
5. verify again.
## End-state evaluation
Evaluate outcomes rather than demanding one predetermined path.
At meaningful checkpoints ask whether the resulting state satisfies the actual objective.
Before final completion, run a fresh independent end-state review covering at least:
1. Requirements completeness.
2. Domain / semantic coherence.
3. Internal consistency and edge cases.
4. Target-user workflows and usability.
5. Architecture and long-term maintainability.
6. Implementation correctness and regression risk.
7. Actual runtime behavior.
8. Documentation / specification adequacy.
9. Remaining assumptions, unknowns, and risks.
Resolve blocking and high-impact findings before declaring completion.
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
Do not weaken completion criteria merely because substantial time or context has been consumed.
Apply these criteria to the scope requested by the user:
The project is complete only when all applicable conditions are satisfied:
- 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.
- The intended user problem is coherently addressed.
- Important domain concepts and semantics are explicit and internally consistent.
- Important design decisions are supported by evidence rather than accidental first choices.
- Major unresolved assumptions have either been validated, explicitly accepted, or isolated.
- Required specifications accurately describe implemented behavior.
- Primary user workflows are implemented.
- The application builds and launches reliably.
- Real workflows have been exercised on the running application.
- Important edge / failure / empty / loading states behave sensibly.
- Relevant automated evaluators and tests pass.
- UI/interaction quality has been inspected on the actual application.
- No known blocker or high-severity defects remain.
- Independent cross-disciplinary review has been completed.
- Valid material findings have been addressed and reverified.
- Architecture is understandable and appropriately extensible.
- Temporary and obsolete scaffolding has been removed.
- Durable project state reflects reality.
- Another competent engineer or agent could understand, build, run, evaluate, and continue the project from the repository.
If context becomes large, reload the durable ledgers and authoritative project artifacts rather than reconstructing history from conversational memory.
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
## Final handoff
Only when the completion criteria are satisfied, provide a concise handoff covering:
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.
- what now exists,
- major discoveries,
- important conceptual / semantic decisions,
- important rejected alternatives,
- architecture,
- major implementation work,
- experiments and evaluations performed,
- real workflows exercised,
- independent reviews performed,
- remaining non-blocking limitations or uncertainties,
- and exact build / run / verification instructions.
## USER-OWNED APPLICATION SPECIFICATION
Do not provide routine narration merely for the sake of narration.
Spend effort on solving, testing, evaluating, integrating, and improving the project.
## Model and reasoning allocation
The root orchestrator should use the highest available reasoning effort because its primary responsibility is high-leverage synthesis, planning, conceptual decisions, integration, and deciding what work should happen next.
Do not automatically use maximum reasoning for every delegated task.
Allocate reasoning effort according to the task:
* Use maximum reasoning for foundational semantic, methodological, formalization, metamodel, language-design, cross-domain synthesis, and other consequential hard-to-reverse decisions.
* Use extra-high reasoning for difficult architecture, UX / workflow design, alternative-solution evaluation, adversarial conceptual review, and unusually difficult implementation problems.
* Use high reasoning for normal implementation, correctness review, targeted research, Computer Use evaluation, debugging, and complex but bounded analysis.
* Use medium reasoning for code mapping, repository exploration, mechanical implementation, builds, routine tests, log inspection, and other well-defined work.
* Use lower effort only for genuinely trivial mechanical tasks.
Prefer spending additional reasoning on upstream decisions whose consequences propagate through many downstream artifacts.
Do not spend maximum reasoning merely because a task is long. Distinguish computational volume from conceptual difficulty.
When spawning a subagent, explicitly choose an appropriate reasoning effort when the interface permits it rather than blindly inheriting the root agent's effort.
Use GPT-6 Astra model exclusively.
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
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. Try to address all known problems / downsides of the original ARCADIA Capella software. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
### 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 1-st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
And the remaining ARCADIA layers will be added in future releases.
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.
@@ -596,24 +57,50 @@ You should implement most of the ARCADIA semantics, while adressing original ARC
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 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
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
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 the 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
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
@@ -621,3 +108,4 @@ Right now, the behavior & scenarios UI is hard to use. It contains static / uned
---
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 -->
+23 -2
View File
@@ -18,12 +18,14 @@ From the repository root:
cargo run --locked -p oa-desktop --bin arcadia-studio
```
Build both desktop and server release executables:
Build the desktop, server and source CLI release executables:
```sh
cargo build --release --workspace --locked
```
Stage the complete teaching examples beside these executables so **Home → Open water-pressure walkthrough** works in an optimized source build. From the repository root, use `Copy-Item -Recurse -Force examples target/release/` in PowerShell or `cp -R examples target/release/` on Linux. If you set a different Cargo target directory or pass `--target`, use that build's executable directory instead of `target/release`. The packaging scripts include these examples automatically.
Run `target/release/arcadia-studio.exe` on Windows or `./target/release/arcadia-studio` on Linux. No task-specific cache directory is required. A graphical desktop and compatible graphics driver are required; the application uses Iced 0.14 with WGPU and X11/Wayland support. It does not require Java, Eclipse, a browser, an account or a running server.
An optional Windows path uses the `stable-x86_64-pc-windows-gnullvm` Rust toolchain with LLVM-MinGW. Put LLVM-MinGW's `bin` directory on `PATH`, including `clang.exe` and `llvm-windres.exe`, then run:
@@ -49,17 +51,34 @@ The earlier V2 package record describes `dist/ArcadiaStudio-Windows.zip` and `di
The current [Windows packaging instructions](docs/windows-packaging.md) stage both executables, verify their runtime DLL dependencies, and produce an archive with checksums and provenance. Native acceptance fixtures and independent expected results are retained in [the release fixture directory](harness/fixtures/release1-native/README.md).
## Work with source files from the command line
The `arcadia` CLI checks an explicit `.arcadia` entry file and its includes without
starting the desktop or server:
```sh
cargo run --locked -p oa-cli --bin arcadia -- check examples/text-first/project.arcadia
```
`stabilize ROOT` previews the identities needed for persistent editing;
`rename-alias ROOT OLD NEW` previews an alias change and its references. Add
`--write` to save that invocation's candidate, or `--json` for structured
diagnostics and changed-file text/diffs. Preview leaves files untouched.
See [source CLI commands](docs/source-cli.md) for examples and exit codes.
## Model operational needs and system responsibilities
The desktop starts at **Home**, where you can create a model, open a local project, continue recent files or recover retained work. Missing recent files stay visible with **Locate**; **Remove** only forgets their history entry. Choose **Explore rescue example** for a model combining people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence.
The example opens in an architecture view with work nested inside its performers. Each layer also has dedicated functional dataflow and mission/capability views. Regenerate both example formats with matching identities using `cargo run --locked -p oa-core --example generate_demo`; the command verifies both saved models. Historical migration fixtures remain separate.
The architecture diagram has a compact side palette; expand it to see tool names. Choose an element tool, click its position, enter its name in place and press **Enter**. **Escape** cancels the draft. Select a card and press **F2** to rename it. Right-click a participant and choose **Add activity** or **Add function** to create work with its performer allocation in one undoable edit. Ordinary palette creation leaves work unallocated. **Add existing** places a compatible model element in the current view; **Include already shown** offers **Locate** for existing placements.
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility. **Communication means** connects operational Entities or Actors directly, with direction and carried items; **Exchange allocation** associates the Interactions it carries. Operational communication requires no system ports.
2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version.
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts.
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. **View properties** stages saved line-kind, category, behavior-coverage, context, label and individual-hide choices. Right-click empty diagram space for **Duplicate view**, retaining the same semantic records in an independent presentation. Placement **Hide** removes a placement; line hiding retains its relationship; **Delete from model** removes semantics and dependent references.
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. Select a State machine and choose **Create diagram** to place its existing regions and states/modes in a saved view. State machines also support availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
5. In **Behavior & scenarios**, choose **New**, select Operational process (OA), Functional chain (SA), Scenario or State machine, then enter its name. For a scenario, choose what its lifelines represent; **Add optional context…** links a context without changing its layer-root ownership. For a state machine, search for its existing participant owner; optionally select **Create the first state view**. Choose **Create and open** to start authoring work contributions, lifelines, ordered occurrences or states. **Cancel · Esc** discards the creation draft; if another dialog is open, Escape dismisses that dialog first. Creation and its optional first view form one Undo operation. A new state machine starts without regions or states; **Create diagram** can later place its existing contents in a saved view. Behavior supports repeated exchanges, fragments/branches, availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. For an existing trace, **Review semantics…** compares typed content and prior observations, with explicit keep, take or final-value decisions for supported structural, scalar, constructor, individual-row and ordered-collection changes. Open additional trace rows and choose **Include this row** to combine reviewed source contributions into one validated Apply/Undo transaction; conflicting decisions require resolution. Review does not automatically synchronize the perspectives. **Attachments** reviews an existing child’s parent or a relationship endpoint. **Artifact links** reviews metadata, owner moves, explicit source-absence deletion and creation of a reserved counterpart after all introduced metadata is reviewed. These controls have automated coverage; their native acceptance remains pending. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft.
@@ -83,6 +102,8 @@ In the state view, **Select / move** drags vertices and containers without repar
## Edit the same model as text
For a short text-only start, open [the distress-response model](examples/text-first/project.arcadia) and follow [its three-step walkthrough](examples/text-first/README.md). Four declarations and three explicit relationships describe participants, work, responsibility and an exchange; no diagram or handwritten UUID is needed.
**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
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "oa-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "arcadia"
path = "src/main.rs"
[dependencies]
oa-core = { path = "../oa-core" }
serde.workspace = true
serde_json.workspace = true
+451
View File
@@ -0,0 +1,451 @@
//! Source-only authoring. Preparation captures bytes; only core owns disk writes.
use oa_core::{
Command, language,
persistence::MigrationReport,
prepare_command,
source_files::{self, DiskProject, SourceFileError},
source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic},
validation::Severity,
};
use serde::Serialize;
use std::path::{Path, PathBuf};
pub const LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1024,
model: language::ParseLimits {
max_source_bytes: 64 * 1024 * 1024,
max_graph_objects: 100_000,
max_records: 250_000,
max_step_references: 500_000,
},
};
#[derive(Debug, Clone)]
pub enum Operation {
Check,
Stabilize,
RenameAlias { old: String, new: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct Location {
pub path: String,
/// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars.
pub start: usize,
pub end: usize,
pub line: Option<usize>,
pub column: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
pub severity: String,
pub code: String,
pub message: String,
pub coordinate_space: String,
pub location: Option<Location>,
pub related: Vec<Location>,
pub object_id: Option<String>,
pub field: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Change {
pub path: String,
/// Exact captured and proposed UTF-8 text, including line endings.
pub before: String,
pub after: String,
pub diff: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub format_version: u32,
pub entry: String,
pub operation: String,
pub status: String,
pub exit_code: u8,
/// True only after a successful changed save. False on failure does not
/// promise rollback: consult the specific error/recovery outcome.
pub written: bool,
pub diagnostics: Vec<Diagnostic>,
pub changes: Vec<Change>,
pub identities_to_stabilize: usize,
pub migration: Option<MigrationReport>,
pub notes: Vec<String>,
}
impl Report {
pub fn failure(
entry: &Path,
operation: &str,
exit_code: u8,
code: &str,
message: String,
) -> Self {
let mut report = Self::empty(entry, operation);
report.fail(exit_code, code, message);
report
}
fn empty(entry: &Path, operation: &str) -> Self {
Self {
format_version: 1,
entry: entry.to_string_lossy().into_owned(),
operation: operation.into(),
status: "checked".into(),
exit_code: 0,
written: false,
diagnostics: vec![],
changes: vec![],
identities_to_stabilize: 0,
migration: None,
notes: vec![],
}
}
fn fail(&mut self, exit_code: u8, code: &str, message: String) {
self.status = "rejected".into();
self.exit_code = exit_code;
self.diagnostics.push(Diagnostic {
severity: "error".into(),
code: code.into(),
message,
coordinate_space: "operation".into(),
location: None,
related: vec![],
object_id: None,
field: None,
});
}
}
fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic {
let d = issue.diagnostic;
Diagnostic {
severity: match d.severity {
Severity::Error => "error",
Severity::Warning => "warning",
}
.into(),
code: d.code,
message: d.message,
coordinate_space: space.into(),
location: Some(Location {
path: issue.path,
start: d.span.start,
end: d.span.end,
line: Some(d.line),
column: Some(d.column),
}),
related: issue
.related
.into_iter()
.map(|at| Location {
path: at.path,
start: at.span.start,
end: at.span.end,
line: None,
column: None,
})
.collect(),
object_id: d.object_id.map(|id| id.to_string()),
field: d.field,
}
}
fn disk_error(report: &mut Report, error: SourceFileError) {
let code = match &error {
SourceFileError::Conflict(_) => "external_change",
SourceFileError::RecoveryRequired(_) => "recovery_required",
SourceFileError::RecoveryFailed { .. } => "recovery_failed",
SourceFileError::Path(_) => "unsafe_path",
SourceFileError::Limit(_) => "source_limit",
SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation",
SourceFileError::Invalid(_) => "invalid_save_candidate",
SourceFileError::Io { .. } => "io_error",
};
report.fail(3, code, error.to_string());
}
/// A candidate owns its original expectations and is consumed by saving.
/// Callers cannot replace its source/model/expectations after preparation.
pub struct Prepared {
report: Report,
snapshot: DiskProject,
compiled: CompiledSourceProject,
mutable: bool,
}
impl Prepared {
pub fn report(&self) -> &Report {
&self.report
}
pub fn into_report(self) -> Report {
self.report
}
pub fn write(mut self) -> Report {
if !self.mutable {
self.report
.fail(2, "usage", "check/lint does not accept --write".into());
return self.report;
}
match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) {
Ok(_) => {
self.report.written = !self.report.changes.is_empty();
self.report.status = if self.report.written {
"written"
} else {
"unchanged"
}
.into();
}
Err(error) => disk_error(&mut self.report, error),
}
self.report
}
}
pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Report>> {
let name = match operation {
Operation::Check => "check",
Operation::Stabilize => "stabilize",
Operation::RenameAlias { .. } => "rename-alias",
};
let mut report = Report::empty(entry, name);
let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| {
disk_error(&mut report, error);
Box::new(report.clone())
})?;
let raw = snapshot.reachable_bundle();
// The loader may include warnings from stabilized compilation. Only its
// discovery/IO diagnostics are authoritative on the original bytes.
let mut diagnostics: Vec<_> = snapshot
.diagnostics
.iter()
.filter(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save"
| "source_encoding"
| "source_limit"
| "invalid_path"
| "missing_include"
| "project_limit"
)
})
.cloned()
.collect();
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
if !diagnostics.contains(&diagnostic) {
diagnostics.push(diagnostic);
}
}
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save" | "missing_include" | "invalid_path"
)
});
let invalid = diagnostics
.iter()
.any(|d| d.diagnostic.severity == Severity::Error);
report.diagnostics = diagnostics
.into_iter()
.map(|d| located(d, "input"))
.collect();
if invalid {
// Do not run declaration analysis or any updater on invalid raw text.
// Failed lint may also have allocated provisional object identities.
for diagnostic in &mut report.diagnostics {
diagnostic.object_id = None;
}
report.status = "invalid".into();
report.exit_code = if disk_refusal { 3 } else { 1 };
return Err(Box::new(report));
}
let explicit_ids: std::collections::BTreeSet<_> = raw
.files
.values()
.flat_map(|text| language::analyze(text).declarations)
.filter_map(|d| d.id.map(|id| id.to_string()))
.collect();
for diagnostic in &mut report.diagnostics {
if diagnostic
.object_id
.as_ref()
.is_some_and(|id| !explicit_ids.contains(id))
{
diagnostic.object_id = None;
}
}
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
report.status = "invalid".into();
report.exit_code = 1;
report
.diagnostics
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
Box::new(report.clone())
})?;
report.identities_to_stabilize = raw
.files
.values()
.map(|text| {
language::analyze(text)
.declarations
.into_iter()
.filter(|d| d.id.is_none())
.count()
})
.sum();
report.migration = compiled.decoded.migration.clone();
let mutable = !matches!(operation, Operation::Check);
if let Operation::RenameAlias { old, new } = operation {
let project = &compiled.decoded.project;
let Some((id, _)) = project
.identities()
.into_iter()
.find(|(_, alias)| *alias == old)
else {
report.fail(
1,
"unknown_alias",
format!("No declaration has alias '{old}'."),
);
return Err(Box::new(report));
};
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
report.fail(1, "semantic_command_rejected", error.to_string());
Box::new(report.clone())
})?;
compiled = source_project::update_bundle_with_identity_map(
&compiled,
&command.project,
&command.identity_map,
LIMITS,
)
.map_err(|diagnostics| {
report.status = "invalid".into();
report.exit_code = 1;
report
.diagnostics
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
Box::new(report.clone())
})?;
}
if mutable {
for (path, after) in &compiled.bundle.files {
let before = raw.files.get(path).cloned().unwrap_or_default();
if &before != after {
report.changes.push(Change {
path: path.clone(),
diff: unified_diff(path, &before, after),
before,
after: after.clone(),
});
}
}
report.status = "preview".into();
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
}
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
Ok(Prepared {
report,
snapshot,
compiled,
mutable,
})
}
/// One bounded hunk per file: common prefix/suffix plus three context lines.
/// Linear in input size; no quadratic LCS allocation for large modules.
fn unified_diff(path: &str, before: &str, after: &str) -> String {
let a: Vec<_> = before.split_inclusive('\n').collect();
let b: Vec<_> = after.split_inclusive('\n').collect();
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
let suffix = a[prefix..]
.iter()
.rev()
.zip(b[prefix..].iter().rev())
.take_while(|(a, b)| a == b)
.count();
let start = prefix.saturating_sub(3);
let a_end = (a.len() - suffix + 3).min(a.len());
let b_end = (b.len() - suffix + 3).min(b.len());
let a_start = if a_end == start { start } else { start + 1 };
let b_start = if b_end == start { start } else { start + 1 };
let mut out = format!(
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
a_end - start,
b_end - start
);
let mut emit = |prefix: char, line: &str| {
out.push(prefix);
out.push_str(line);
if !line.ends_with('\n') {
out.push_str("\n\\ No newline at end of file\n");
}
};
for line in &a[start..prefix] {
emit(' ', line);
}
for line in &a[prefix..a.len() - suffix] {
emit('-', line);
}
for line in &b[prefix..b.len() - suffix] {
emit('+', line);
}
for line in &a[a.len() - suffix..a_end] {
emit(' ', line);
}
out
}
pub struct Arguments {
pub entry: PathBuf,
pub operation: Operation,
pub write: bool,
pub json: bool,
}
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
let mut positional = Vec::new();
let (mut write, mut json) = (false, false);
for arg in args {
if arg == "--write" && !write {
write = true;
} else if arg == "--json" && !json {
json = true;
} else if arg.to_string_lossy().starts_with('-') {
return Err(format!(
"Unknown or repeated option: {}",
arg.to_string_lossy()
));
} else {
positional.push(arg);
}
}
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
let operation = match (command, positional.len()) {
("check" | "lint", 2) if !write => Operation::Check,
("stabilize", 2) => Operation::Stabilize,
("rename-alias", 4) => Operation::RenameAlias {
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
},
_ => return Err(HELP.into()),
};
Ok(Arguments {
entry: PathBuf::from(positional[1]),
operation,
write,
json,
})
}
+102
View File
@@ -0,0 +1,102 @@
use oa_cli::{Report, arguments, prepare};
use std::{
io::{self, Write},
path::Path,
process::ExitCode,
};
fn human_report(report: &Report, show_diff: bool) -> io::Result<()> {
let mut err = io::stderr().lock();
for d in &report.diagnostics {
let at = d
.location
.as_ref()
.map(|l| {
format!(
"{}:{}:{}",
l.path,
l.line.unwrap_or(0),
l.column.unwrap_or(0)
)
})
.unwrap_or_else(|| report.entry.clone());
writeln!(
err,
"{at}: {}[{}] ({}): {}",
d.severity, d.code, d.coordinate_space, d.message
)?;
}
writeln!(
err,
"{}: {} file changes; {} identities to stabilize",
report.status,
report.changes.len(),
report.identities_to_stabilize
)?;
if let Some(migration) = &report.migration {
writeln!(
err,
"Source migration: schema {} → {} (separate from identity stabilization)",
migration.from_schema, migration.to_schema
)?;
}
for note in &report.notes {
writeln!(err, "{note}")?;
}
if show_diff {
let mut out = io::stdout().lock();
for change in &report.changes {
write!(out, "{}", change.diff)?;
}
out.flush()?;
}
err.flush()
}
fn run() -> io::Result<u8> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
io::stdout().write_all(oa_cli::HELP.as_bytes())?;
return Ok(0);
}
if args.len() == 1 && args[0] == "--version" {
writeln!(io::stdout(), "arcadia {}", env!("CARGO_PKG_VERSION"))?;
return Ok(0);
}
let json = args.iter().any(|a| a == "--json");
let mut displayed = false;
let report = match arguments(&args) {
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
Ok(args) => match prepare(&args.entry, args.operation) {
Err(report) => *report,
Ok(prepared) => {
if args.write {
if !args.json {
human_report(prepared.report(), true)?;
displayed = true;
}
prepared.write()
} else {
prepared.into_report()
}
}
},
};
if json {
serde_json::to_writer_pretty(io::stdout().lock(), &report)?;
writeln!(io::stdout())?;
} else {
human_report(&report, !displayed)?;
}
Ok(report.exit_code)
}
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(code),
Err(error) => {
let _ = writeln!(io::stderr(), "Output failed: {error}");
ExitCode::from(3)
}
}
}
+425
View File
@@ -0,0 +1,425 @@
use oa_cli::{LIMITS, Operation, Prepared, prepare};
use oa_core::{Id, Project, source_files, source_project};
use serde_json::Value;
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::Command,
};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4()));
fs::create_dir(&path).unwrap();
Self(path)
}
fn put(&self, name: &str, text: &str) {
let path = self.0.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn entry(&self) -> PathBuf {
self.0.join("project.arcadia")
}
fn read(&self, name: &str) -> String {
fs::read_to_string(self.0.join(name)).unwrap()
}
fn prepare(&self, op: Operation) -> Prepared {
prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}"))
}
fn model(&self) -> Project {
let disk = source_files::load_project(&self.entry(), LIMITS).unwrap();
source_project::compile_bundle(&disk.reachable_bundle(), LIMITS)
.unwrap()
.decoded
.project
}
fn cli(&self, args: &[&str]) -> (i32, Value) {
let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia"));
command
.arg(args[0])
.arg(self.entry())
.args(&args[1..])
.arg("--json");
let output = command.output().unwrap();
assert!(
output.stderr.is_empty(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
(
output.status.code().unwrap(),
serde_json::from_slice(&output.stdout).unwrap(),
)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n";
#[test]
fn complete_alarm_example_preview_then_write_and_fixed_point() {
let fixture = Fixture::new();
let source = include_str!(
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
);
fixture.put("project.arcadia", source);
let (code, check) = fixture.cli(&["check"]);
assert_eq!(code, 0);
assert_eq!(check["diagnostics"], serde_json::json!([]));
let (code, preview) = fixture.cli(&["stabilize"]);
assert_eq!(code, 0);
assert_eq!(preview["identities_to_stabilize"], 40);
assert_eq!(preview["status"], "preview");
assert_eq!(fixture.read("project.arcadia"), source);
assert!(
preview["changes"][0]["diff"]
.as_str()
.unwrap()
.contains("+project alarm_response")
);
let (code, written) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
let saved = fixture.read("project.arcadia");
assert_eq!(written["changes"][0]["after"], saved);
let project = fixture.model();
assert_eq!(project.identities().len(), 40);
let mut stripped = saved.clone();
for (id, _) in project.identities() {
stripped = stripped.replace(&format!(" id \"{id}\""), "");
}
assert_eq!(stripped, source, "only identity clauses may be inserted");
let (code, again) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
assert_eq!(again["changes"], serde_json::json!([]));
assert_eq!(again["status"], "unchanged");
assert_eq!(fixture.model(), project);
assert_eq!(fixture.read("project.arcadia"), saved);
}
#[test]
fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let candidate = fixture.prepare(Operation::Stabilize);
let expected = candidate.report().changes[0].after.clone();
assert_eq!(candidate.report().identities_to_stabilize, 2);
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
assert_eq!(candidate.write().exit_code, 0);
assert_eq!(fixture.read("project.arcadia"), expected);
assert!(expected.contains("// 雨: authored defaults stay omitted\r\n"));
assert!(!expected.contains("description"));
let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap();
let report = fixture.prepare(Operation::Stabilize).write();
assert_eq!(report.status, "unchanged");
assert!(!report.written);
assert_eq!(
fs::metadata(fixture.entry()).unwrap().modified().unwrap(),
metadata
);
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
}
#[test]
fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() {
let fixture = Fixture::new();
fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n");
fixture.put(
"participants.arcadia",
"// @team is quoted documentation\noa_entity team \"Team @team\" {}\n",
);
fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n");
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let before = fixture.model();
let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"]
.into_iter()
.map(|p| (p, fixture.read(p)))
.collect();
let team = before
.identities()
.into_iter()
.find(|(_, alias)| *alias == "team")
.unwrap()
.0;
let candidate = fixture.prepare(Operation::RenameAlias {
old: "team".into(),
new: "rescue_team".into(),
});
assert_eq!(candidate.report().changes.len(), 2);
assert_eq!(fixture.model(), before, "preview cannot mutate");
assert_eq!(candidate.write().exit_code, 0);
let mut expected = before;
expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into();
assert_eq!(fixture.model(), expected);
assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]);
assert_eq!(
fixture.read("participants.arcadia"),
original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1)
);
assert_eq!(
fixture.read("work.arcadia"),
original["work.arcadia"].replace("source @team;", "source @rescue_team;")
);
}
#[test]
fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() {
let fixture = Fixture::new();
let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n";
fixture.put("project.arcadia", raw);
for command in [
vec!["check"],
vec!["stabilize", "--write"],
vec!["rename-alias", "a", "b", "--write"],
] {
let (code, report) = fixture.cli(&command);
assert_eq!(code, 1);
assert_eq!(fixture.read("project.arcadia"), raw);
let diagnostics = report["diagnostics"].as_array().unwrap();
assert!(!diagnostics.is_empty());
for d in diagnostics {
assert_eq!(d["coordinate_space"], "input");
let at = &d["location"];
let start = at["start"].as_u64().unwrap() as usize;
assert!(raw.is_char_boundary(start));
assert_eq!(
at["line"].as_u64().unwrap() as usize,
raw[..start].bytes().filter(|b| *b == b'\n').count() + 1
);
let line = raw[..start]
.rsplit_once('\n')
.map_or(&raw[..start], |(_, tail)| tail);
assert_eq!(
at["column"].as_u64().unwrap() as usize,
line.chars().count() + 1
);
}
}
}
#[test]
fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() {
let fixture = Fixture::new();
let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n";
fixture.put("project.arcadia", raw);
let (code, result) = fixture.cli(&["lint"]);
assert_eq!(code, 0, "engineering warnings do not reject admission");
let warning = result["diagnostics"]
.as_array()
.unwrap()
.iter()
.find(|d| d["code"] == "work_without_behavior_context")
.unwrap();
assert_eq!(warning["severity"], "warning");
assert_eq!(
warning["location"]["start"],
raw.find("oa_activity").unwrap()
);
assert_eq!(warning["location"]["line"], 3);
assert_eq!(warning["location"]["column"], 5);
assert!(warning["object_id"].is_null());
assert_eq!(fixture.read("project.arcadia"), raw);
}
#[test]
fn captured_dependency_change_refuses_all_writes_even_on_noop() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let pending = fixture.prepare(Operation::Stabilize);
let external = format!("{SIMPLE}// editor changed this\n");
fixture.put("project.arcadia", &external);
let result = pending.write();
assert_eq!(result.exit_code, 3);
assert_eq!(result.diagnostics.last().unwrap().code, "external_change");
assert_eq!(fixture.read("project.arcadia"), external);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let noop = fixture.prepare(Operation::Stabilize);
assert!(noop.report().changes.is_empty());
let latest = format!("{}// another edit\n", fixture.read("project.arcadia"));
fixture.put("project.arcadia", &latest);
assert_eq!(noop.write().exit_code, 3);
assert_eq!(fixture.read("project.arcadia"), latest);
}
#[test]
fn pending_journal_refuses_read_preview_and_write_without_recovery() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let noop = fixture.prepare(Operation::Stabilize);
let source = fixture.read("project.arcadia");
// An opaque incomplete journal must never be interpreted/recovered by CLI.
let journal = fixture.0.join(".arcadia-save-project.arcadia.journal");
fs::create_dir(&journal).unwrap();
fs::write(journal.join("sentinel"), b"retain").unwrap();
for op in [Operation::Check, Operation::Stabilize] {
let report = prepare(&fixture.entry(), op).err().unwrap();
assert_eq!(report.exit_code, 3);
assert!(report.diagnostics.iter().any(|d| d.code == "pending_save"));
}
assert_eq!(
noop.write().exit_code,
3,
"saver must recheck after preview"
);
assert_eq!(fixture.read("project.arcadia"), source);
assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain");
}
#[test]
fn rejected_alias_and_same_alias_preserve_source() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let source = fixture.read("project.arcadia");
for (old, new) in [
("team", "rescue"),
("team", "invalid alias"),
("absent", "valid"),
] {
let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]);
assert_eq!(code, 1);
assert_eq!(result["status"], "rejected");
assert_eq!(fixture.read("project.arcadia"), source);
}
let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]);
assert_eq!(code, 0);
assert_eq!(result["status"], "unchanged");
assert_eq!(fixture.read("project.arcadia"), source);
}
#[test]
fn rename_shows_missing_identity_and_separate_source_migration() {
let fixture = Fixture::new();
let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n";
fixture.put("project.arcadia", old);
let candidate = fixture.prepare(Operation::RenameAlias {
old: "team".into(),
new: "crew".into(),
});
let report = candidate.report();
assert_eq!(report.identities_to_stabilize, 2);
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
assert!(report.changes[0].diff.contains("-arcadia 5;"));
assert!(report.changes[0].diff.contains("+arcadia 7;"));
assert!(report.changes[0].after.contains("oa_entity crew"));
assert_eq!(fixture.read("project.arcadia"), old);
assert_eq!(candidate.write().exit_code, 0);
}
#[test]
fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let output = Command::new(env!("CARGO_BIN_EXE_arcadia"))
.current_dir(&fixture.0)
.args(["check", "--json"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
let error: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(error["diagnostics"][0]["code"], "usage");
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
let (code, _) = fixture.cli(&["check", "--write"]);
assert_eq!(code, 2);
assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file());
}
#[test]
fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() {
let fixture = Fixture::new();
let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }";
fixture.put("project.arcadia", entry);
let (code, report) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 3, "a disk dependency could not be read");
assert!(
report["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "missing_include")
);
fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap();
let (code, report) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 1, "invalid encoding is invalid raw source");
assert!(
report["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "source_encoding")
);
assert_eq!(fixture.read("project.arcadia"), entry);
assert_eq!(
fs::read(fixture.0.join("part.arcadia")).unwrap(),
[0xff, 0xfe]
);
for raw in [
"arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }",
"arcadia 2; project p \"P\" {}",
] {
fixture.put("project.arcadia", raw);
let (code, _) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 1);
assert_eq!(fixture.read("project.arcadia"), raw);
}
}
#[test]
fn non_element_alias_detached_file_and_unchanged_dependency_conflict() {
let fixture = Fixture::new();
fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }");
fixture.put("part.arcadia", "oa_entity team \"Team\" {}");
fixture.put(
"detached.arcadia",
"invalid detached draft must stay exact 雨",
);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let before = fixture.model();
let relation = before
.identities()
.into_iter()
.find(|(_, alias)| *alias == "responsible")
.unwrap()
.0;
let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]);
assert_eq!(code, 0);
let mut expected = before;
expected.relations.get_mut(&relation).unwrap().alias = "performs".into();
assert_eq!(fixture.model(), expected);
assert!(
fixture
.read("project.arcadia")
.contains("@ // keep edge comment\nteam")
);
let captured = fixture.prepare(Operation::RenameAlias {
old: "p".into(),
new: "rescue".into(),
});
assert!(
captured
.report()
.changes
.iter()
.all(|c| c.path != "part.arcadia")
);
let root_before = fixture.read("project.arcadia");
fixture.put("part.arcadia", "// external unchanged-dependency edit\n");
assert_eq!(captured.write().exit_code, 3);
assert_eq!(fixture.read("project.arcadia"), root_before);
assert_eq!(
fixture.read("part.arcadia"),
"// external unchanged-dependency edit\n"
);
assert_eq!(
fixture.read("detached.arcadia"),
"invalid detached draft must stay exact 雨"
);
}
+5 -1
View File
@@ -7,11 +7,15 @@ rust-version.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["unbounded_depth"] }
stacker = "0.1.25"
uuid.workspace = true
thiserror.workspace = true
tempfile = "3"
same-file = "1"
num-bigint = "0.5.1"
num-integer = "0.1.47"
num-traits = "0.2.19"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
+58
View File
@@ -0,0 +1,58 @@
//! Schema 8 engineering definitions and static admission. No expression evaluation.
mod admission;
mod expression_lifecycle;
pub(crate) mod expression_serde;
pub mod references;
mod resolve;
mod typecheck;
mod types;
pub(crate) use admission::admit_structure_checked;
pub use admission::{AdmissionLimits, AdmissionUsage, admit};
pub use resolve::{EffectiveBinding, KindHandle, ResolvedAnalysis, ResolvedScalar, ResolvedType};
pub use types::*;
/// Ratified semantic bounds. Aggregate admission work is a separate caller policy.
pub const MAX_EXPRESSION_DEPTH: usize = 128;
pub const MAX_CLOSURE_NODES: usize = 65_536;
pub const MAX_LIST_LENGTH: usize = 1_024;
pub const MAX_CALL_DEPTH: usize = 32;
/// Additional persisted records for existing transport ParseLimits. Definitions
/// themselves count as graph objects there; this counts parameters, overrides,
/// and every stored expression node, with saturating arithmetic and no recursion.
pub(crate) fn nested_record_count(project: &crate::Project) -> usize {
let mut count = 0usize;
fn binding(value: &Binding, count: &mut usize) {
if let Binding::Expression(expression) = value {
references::walk(expression, &mut |_| {
*count = count.saturating_add(1);
});
}
}
for definition in project.definitions.values() {
if let DefinitionBody::Calculation(calculation) = &definition.body {
count = count.saturating_add(calculation.parameters.len());
binding(&calculation.body, &mut count);
}
}
for element in project.elements.values() {
match element.shared() {
Some(crate::SharedBody::EngineeringProperty { base, .. }) => binding(base, &mut count),
Some(crate::SharedBody::AnalysisCase { overrides, .. }) => {
count = count.saturating_add(overrides.len());
for value in overrides.values() {
binding(value, &mut count);
}
}
Some(crate::SharedBody::AnalysisCheck { predicate, .. }) => {
binding(predicate, &mut count)
}
_ => {}
}
}
count
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,377 @@
//! Ordinary expression ownership never consumes stack proportional to tree depth.
//! This also covers a partially decoded expression and rejected programmatic input.
use super::Expr;
use std::{fmt, mem};
impl Expr {
fn clone_shell(&self) -> Self {
let leaf = || Box::new(Self::Boolean(false));
match self {
Self::Boolean(value) => Self::Boolean(*value),
Self::Number(value) => Self::Number(value.clone()),
Self::Quantity { magnitude, unit } => Self::Quantity {
magnitude: magnitude.clone(),
unit: *unit,
},
Self::Property(id) => Self::Property(*id),
Self::Parameter(id) => Self::Parameter(*id),
Self::List(values) => {
Self::List((0..values.len()).map(|_| Self::Boolean(false)).collect())
}
Self::Unary { op, .. } => Self::Unary {
op: *op,
operand: leaf(),
},
Self::Binary { op, .. } => Self::Binary {
op: *op,
left: leaf(),
right: leaf(),
},
Self::If { .. } => Self::If {
condition: leaf(),
when_true: leaf(),
when_false: leaf(),
},
Self::Call {
definition,
arguments,
} => Self::Call {
definition: *definition,
arguments: arguments
.keys()
.map(|id| (*id, Self::Boolean(false)))
.collect(),
},
Self::Sum { .. } => Self::Sum {
values: leaf(),
initial: leaf(),
},
Self::Power { exponent, .. } => Self::Power {
base: leaf(),
exponent: *exponent,
},
Self::AsKind { kind, .. } => Self::AsKind {
kind: *kind,
value: leaf(),
},
}
}
fn detach_children(&mut self, pending: &mut Vec<Self>) {
let mut take = |child: &mut Box<Self>| {
pending.push(mem::replace(child.as_mut(), Self::Boolean(false)));
};
match self {
Self::Unary { operand, .. } => take(operand),
Self::Binary { left, right, .. } => {
take(left);
take(right);
}
Self::If {
condition,
when_true,
when_false,
} => {
take(condition);
take(when_true);
take(when_false);
}
Self::Sum { values, initial } => {
take(values);
take(initial);
}
Self::Power { base, .. } => take(base),
Self::AsKind { value, .. } => take(value),
Self::List(values) => pending.append(values),
Self::Call { arguments, .. } => {
pending.extend(mem::take(arguments).into_values());
}
Self::Boolean(_)
| Self::Number(_)
| Self::Quantity { .. }
| Self::Property(_)
| Self::Parameter(_) => {}
}
}
}
impl Drop for Expr {
fn drop(&mut self) {
let mut pending = Vec::new();
self.detach_children(&mut pending);
while let Some(mut node) = pending.pop() {
node.detach_children(&mut pending);
// node now owns only leaves/empty collections. Its ordinary drop
// reenters this method at most once, independently of original depth.
}
}
}
impl Clone for Expr {
fn clone(&self) -> Self {
let mut result = Self::Boolean(false);
let mut pending = vec![(self, &mut result)];
while let Some((source, destination)) = pending.pop() {
*destination = source.clone_shell();
match (source, destination) {
(Self::List(a), Self::List(b)) => pending.extend(a.iter().zip(b.iter_mut())),
(Self::Unary { operand: a, .. }, Self::Unary { operand: b, .. })
| (Self::Power { base: a, .. }, Self::Power { base: b, .. })
| (Self::AsKind { value: a, .. }, Self::AsKind { value: b, .. }) => {
pending.push((a, b));
}
(
Self::Binary {
left: a, right: b, ..
},
Self::Binary {
left: c, right: d, ..
},
)
| (
Self::Sum {
values: a,
initial: b,
},
Self::Sum {
values: c,
initial: d,
},
) => {
pending.push((a, c));
pending.push((b, d));
}
(
Self::If {
condition: a,
when_true: b,
when_false: c,
},
Self::If {
condition: d,
when_true: e,
when_false: f,
},
) => {
pending.push((a, d));
pending.push((b, e));
pending.push((c, f));
}
(Self::Call { arguments: a, .. }, Self::Call { arguments: b, .. }) => {
pending.extend(a.values().zip(b.values_mut()));
}
_ => {}
}
}
result
}
}
impl PartialEq for Expr {
fn eq(&self, other: &Self) -> bool {
let mut pending = vec![(self, other)];
while let Some((a, b)) = pending.pop() {
match (a, b) {
(Self::Boolean(a), Self::Boolean(b)) if a == b => {}
(Self::Number(a), Self::Number(b)) if a == b => {}
(
Self::Quantity {
magnitude: a,
unit: b,
},
Self::Quantity {
magnitude: c,
unit: d,
},
) if a == c && b == d => {}
(Self::Property(a), Self::Property(b)) if a == b => {}
(Self::Parameter(a), Self::Parameter(b)) if a == b => {}
(Self::List(a), Self::List(b)) if a.len() == b.len() => {
pending.extend(a.iter().zip(b.iter()));
}
(Self::Unary { op: a, operand: b }, Self::Unary { op: c, operand: d })
if a == c =>
{
pending.push((b, d));
}
(
Self::Binary {
op: a,
left: b,
right: c,
},
Self::Binary {
op: d,
left: e,
right: f,
},
) if a == d => {
pending.push((b, e));
pending.push((c, f));
}
(
Self::If {
condition: a,
when_true: b,
when_false: c,
},
Self::If {
condition: d,
when_true: e,
when_false: f,
},
) => {
pending.push((a, d));
pending.push((b, e));
pending.push((c, f));
}
(
Self::Call {
definition: a,
arguments: b,
},
Self::Call {
definition: c,
arguments: d,
},
) if a == c && b.len() == d.len() => {
for ((key_a, value_a), (key_b, value_b)) in b.iter().zip(d.iter()) {
if key_a != key_b {
return false;
}
pending.push((value_a, value_b));
}
}
(
Self::Sum {
values: a,
initial: b,
},
Self::Sum {
values: c,
initial: d,
},
) => {
pending.push((a, c));
pending.push((b, d));
}
(
Self::Power {
base: a,
exponent: b,
},
Self::Power {
base: c,
exponent: d,
},
) if b == d => {
pending.push((a, c));
}
(Self::AsKind { kind: a, value: b }, Self::AsKind { kind: c, value: d })
if a == c =>
{
pending.push((b, d));
}
_ => return false,
}
}
true
}
}
impl Eq for Expr {}
impl fmt::Debug for Expr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
enum Task<'a> {
Node(&'a Expr),
Text(&'static str),
Scalar(&'a dyn fmt::Debug),
}
use Task::*;
let mut pending = vec![Node(self)];
while let Some(task) = pending.pop() {
match task {
Text(text) => f.write_str(text)?,
Scalar(value) => write!(f, "{value:?}")?,
Node(node) => match node {
Self::Boolean(value) => write!(f, "Boolean({value:?})")?,
Self::Number(value) => write!(f, "Number({value:?})")?,
Self::Quantity { magnitude, unit } => {
write!(f, "Quantity {{ magnitude: {magnitude:?}, unit: {unit:?} }}")?;
}
Self::Property(id) => write!(f, "Property({id:?})")?,
Self::Parameter(id) => write!(f, "Parameter({id:?})")?,
Self::List(values) => {
f.write_str("List([")?;
pending.push(Text("])"));
for (index, value) in values.iter().enumerate().rev() {
pending.push(Node(value));
if index > 0 {
pending.push(Text(", "));
}
}
}
Self::Unary { op, operand } => {
write!(f, "Unary {{ op: {op:?}, operand: ")?;
pending.extend([Text(" }"), Node(operand)]);
}
Self::Binary { op, left, right } => {
write!(f, "Binary {{ op: {op:?}, left: ")?;
pending.extend([Text(" }"), Node(right), Text(", right: "), Node(left)]);
}
Self::If {
condition,
when_true,
when_false,
} => {
f.write_str("If { condition: ")?;
pending.extend([
Text(" }"),
Node(when_false),
Text(", when_false: "),
Node(when_true),
Text(", when_true: "),
Node(condition),
]);
}
Self::Call {
definition,
arguments,
} => {
write!(f, "Call {{ definition: {definition:?}, arguments: {{")?;
pending.push(Text("} }"));
for (index, (id, value)) in arguments.iter().enumerate().rev() {
pending.extend([Node(value), Text(": "), Scalar(id)]);
if index > 0 {
pending.push(Text(", "));
}
}
}
Self::Sum { values, initial } => {
f.write_str("Sum { values: ")?;
pending.extend([
Text(" }"),
Node(initial),
Text(", initial: "),
Node(values),
]);
}
Self::Power { base, exponent } => {
f.write_str("Power { base: ")?;
pending.extend([
Text(" }"),
Scalar(exponent),
Text(", exponent: "),
Node(base),
]);
}
Self::AsKind { kind, value } => {
write!(f, "AsKind {{ kind: {kind:?}, value: ")?;
pending.extend([Text(" }"), Node(value)]);
}
},
}
}
Ok(())
}
}
@@ -0,0 +1,261 @@
//! The existing externally tagged Serde schema, with admission at each Expr edge.
//! The private remote derive is the sole wire grammar: it still rejects unknown
//! fields/variants, duplicate fields and malformed UUIDs/Exact values.
use super::{
BinaryOp, DefinitionRef, Expr, MAX_CLOSURE_NODES, MAX_EXPRESSION_DEPTH, MAX_LIST_LENGTH,
ParameterId, UnaryOp,
};
use crate::{Id, analysis_numeric::Exact};
use serde::{
Deserialize, Serialize,
de::{DeserializeSeed, MapAccess, SeqAccess, Visitor},
};
use std::{cell::Cell, collections::BTreeMap, fmt};
pub(crate) const MAX_JSON_EXPRESSION_NODES: usize = 262_144;
// These are requested segment capacities, not an allocator or RSS guarantee.
const RED_ZONE: usize = 64 * 1024;
const STACK_SEGMENT: usize = 512 * 1024;
pub(crate) fn with_stack<R>(operation: impl FnOnce() -> R) -> R {
stacker::maybe_grow(RED_ZONE, STACK_SEGMENT, operation)
}
#[derive(Clone, Copy)]
struct Meter {
active: bool,
depth: usize,
nodes: usize,
tree_nodes: usize,
maximum: usize,
}
impl Meter {
const DEFAULT: Self = Self {
active: false,
depth: 0,
nodes: 0,
tree_nodes: 0,
maximum: MAX_JSON_EXPRESSION_NODES,
};
}
thread_local! {
static DECODING: Cell<Meter> = const { Cell::new(Meter::DEFAULT) };
static ENCODING: Cell<Meter> = const { Cell::new(Meter::DEFAULT) };
}
type LocalMeter = &'static std::thread::LocalKey<Cell<Meter>>;
/// One codec operation owns the aggregate meter across all disjoint bindings.
/// Scope restoration also runs on errors and unwinding; no mutable TLS borrow is
/// held across Serde/user code or a stack switch.
pub(crate) struct Scope {
local: LocalMeter,
previous: Meter,
}
impl Scope {
pub(crate) fn decoding(maximum: usize) -> Self {
Self::new(&DECODING, maximum)
}
pub(crate) fn encoding(maximum: usize) -> Self {
Self::new(&ENCODING, maximum)
}
fn new(local: LocalMeter, maximum: usize) -> Self {
let next = Meter {
active: true,
maximum: maximum.min(MAX_JSON_EXPRESSION_NODES),
..Meter::DEFAULT
};
let previous = local.with(|meter| meter.replace(next));
Self { local, previous }
}
}
impl Drop for Scope {
fn drop(&mut self) {
self.local.with(|meter| meter.set(self.previous));
}
}
struct NodeGuard {
local: LocalMeter,
root: bool,
}
impl NodeGuard {
fn enter(local: LocalMeter) -> Result<Self, &'static str> {
local.with(|slot| {
let mut meter = slot.get();
let root = !meter.active;
if root {
meter = Meter {
active: true,
..Meter::DEFAULT
};
}
if meter.depth == 0 {
meter.tree_nodes = 0;
}
if meter.depth >= MAX_EXPRESSION_DEPTH {
return Err("Expression logical depth exceeds 128.");
}
if meter.nodes >= meter.maximum {
return Err("JSON expression allocation count exceeded.");
}
if meter.tree_nodes >= MAX_CLOSURE_NODES {
return Err("Stored expression node count exceeds 65536.");
}
meter.depth += 1;
meter.nodes += 1;
meter.tree_nodes += 1;
slot.set(meter);
Ok(Self { local, root })
})
}
}
impl Drop for NodeGuard {
fn drop(&mut self) {
self.local.with(|slot| {
let mut meter = slot.get();
meter.depth -= 1;
if self.root {
meter.active = false;
}
slot.set(meter);
});
}
}
impl<'de> Deserialize<'de> for Expr {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let _node = NodeGuard::enter(&DECODING).map_err(serde::de::Error::custom)?;
with_stack(|| WireExpr::deserialize(deserializer))
}
}
impl Serialize for Expr {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let _node = NodeGuard::enter(&ENCODING).map_err(serde::ser::Error::custom)?;
match self {
Self::List(values) if values.len() > MAX_LIST_LENGTH => {
return Err(serde::ser::Error::custom(
"Expression List exceeds 1024 items.",
));
}
Self::Call { arguments, .. } if arguments.len() > MAX_CLOSURE_NODES => {
return Err(serde::ser::Error::custom(
"Call argument count exceeds 65536.",
));
}
_ => {}
}
with_stack(|| WireExpr::serialize(self, serializer))
}
}
// A private remote derive avoids exposing an unguarded inherent deserialize on
// public Expr. Children remain Expr, so every recursive edge enters the guard.
#[derive(Serialize, Deserialize)]
#[serde(remote = "Expr", deny_unknown_fields)]
enum WireExpr {
Boolean(bool),
Number(Exact),
Quantity {
magnitude: Exact,
unit: DefinitionRef,
},
Property(Id),
Parameter(ParameterId),
List(#[serde(deserialize_with = "bounded_list")] Vec<Expr>),
Unary {
op: UnaryOp,
operand: Box<Expr>,
},
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
If {
condition: Box<Expr>,
when_true: Box<Expr>,
when_false: Box<Expr>,
},
Call {
definition: DefinitionRef,
#[serde(deserialize_with = "bounded_arguments")]
arguments: BTreeMap<ParameterId, Expr>,
},
Sum {
values: Box<Expr>,
initial: Box<Expr>,
},
Power {
base: Box<Expr>,
exponent: i8,
},
AsKind {
kind: DefinitionRef,
value: Box<Expr>,
},
}
struct ListItem {
allowed: bool,
}
impl<'de> DeserializeSeed<'de> for ListItem {
type Value = Expr;
fn deserialize<D: serde::Deserializer<'de>>(self, deserializer: D) -> Result<Expr, D::Error> {
if !self.allowed {
return Err(serde::de::Error::custom(
"Expression List exceeds 1024 items.",
));
}
Expr::deserialize(deserializer)
}
}
fn bounded_list<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Expr>, D::Error> {
struct ListVisitor;
impl<'de> Visitor<'de> for ListVisitor {
type Value = Vec<Expr>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("at most 1024 expression items")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut values = Vec::new();
while let Some(value) = access.next_element_seed(ListItem {
allowed: values.len() < MAX_LIST_LENGTH,
})? {
values.push(value);
}
Ok(values)
}
}
deserializer.deserialize_seq(ListVisitor)
}
fn bounded_arguments<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<ParameterId, Expr>, D::Error> {
struct Arguments;
impl<'de> Visitor<'de> for Arguments {
type Value = BTreeMap<ParameterId, Expr>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("unique scoped parameter UUID keys")
}
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut values = BTreeMap::new();
while let Some(id) = access.next_key::<ParameterId>()? {
if values.contains_key(&id) {
return Err(serde::de::Error::custom(format!(
"Duplicate parameter ID {}",
id.0
)));
}
if values.len() >= MAX_CLOSURE_NODES {
return Err(serde::de::Error::custom(
"Call argument count exceeds 65536.",
));
}
// Count/duplicate checks precede construction of the next subtree.
values.insert(id, access.next_value()?);
}
Ok(values)
}
}
deserializer.deserialize_map(Arguments)
}
+246
View File
@@ -0,0 +1,246 @@
//! Typed live reference domains; parameter IDs and opaque historical captures are separate.
use super::*;
use crate::{Element, Id, Project, SharedBody};
use std::collections::BTreeSet;
pub fn expression_properties(expression: &Expr) -> BTreeSet<Id> {
let mut found = BTreeSet::new();
walk(expression, &mut |node| {
if let Expr::Property(id) = node {
found.insert(*id);
}
});
found
}
pub fn expression_definitions(expression: &Expr) -> BTreeSet<Id> {
let mut found = BTreeSet::new();
walk(expression, &mut |node| match node {
Expr::Quantity { unit, .. } => {
found.insert(unit.local());
}
Expr::Call { definition, .. } => {
found.insert(definition.local());
}
Expr::AsKind { kind, .. } => {
found.insert(kind.local());
}
_ => {}
});
found
}
pub(crate) fn walk<'a>(expression: &'a Expr, visit: &mut impl FnMut(&'a Expr)) {
let mut pending = vec![expression];
while let Some(node) = pending.pop() {
visit(node);
match node {
Expr::List(items) => pending.extend(items.iter().rev()),
Expr::Unary { operand, .. } => pending.push(operand),
Expr::Binary { left, right, .. } => {
pending.push(right);
pending.push(left);
}
Expr::If {
condition,
when_true,
when_false,
} => {
pending.push(when_false);
pending.push(when_true);
pending.push(condition);
}
Expr::Call { arguments, .. } => pending.extend(arguments.values().rev()),
Expr::Sum { values, initial } => {
pending.push(values);
pending.push(initial);
}
Expr::Power { base, .. } => pending.push(base),
Expr::AsKind { value, .. } => pending.push(value),
_ => {}
}
}
}
fn walk_mut(expression: &mut Expr, visit: &mut impl FnMut(&mut Expr)) {
let mut pending = vec![expression];
while let Some(node) = pending.pop() {
visit(node);
match node {
Expr::List(items) => pending.extend(items.iter_mut()),
Expr::Unary { operand, .. } => pending.push(operand),
Expr::Binary { left, right, .. } => {
pending.push(left);
pending.push(right);
}
Expr::If {
condition,
when_true,
when_false,
} => {
pending.push(condition);
pending.push(when_true);
pending.push(when_false);
}
Expr::Call { arguments, .. } => pending.extend(arguments.values_mut()),
Expr::Sum { values, initial } => {
pending.push(values);
pending.push(initial);
}
Expr::Power { base, .. } => pending.push(base),
Expr::AsKind { value, .. } => pending.push(value),
_ => {}
}
}
}
pub fn element_model_references(element: &Element) -> BTreeSet<Id> {
let mut result = BTreeSet::new();
let mut binding = |value: &Binding| {
if let Binding::Expression(expression) = value {
result.extend(expression_properties(expression));
}
};
match element.shared() {
Some(SharedBody::EngineeringProperty { base, .. }) => binding(base),
Some(SharedBody::AnalysisCase { overrides, .. }) => {
for value in overrides.values() {
binding(value);
}
}
Some(SharedBody::AnalysisCheck { predicate, .. }) => binding(predicate),
_ => {}
}
match element.shared() {
Some(SharedBody::EngineeringProperty { subject, .. }) => {
result.insert(subject.id());
}
Some(SharedBody::AnalysisCase {
subject, overrides, ..
}) => {
result.extend(subject.map(AnalysisSubject::id));
result.extend(overrides.keys());
}
Some(SharedBody::AnalysisCheck {
subject,
documented_constraint,
..
}) => {
result.extend(subject.map(AnalysisSubject::id));
result.extend(documented_constraint);
}
_ => {}
}
result
}
fn remap_ref(reference: &mut DefinitionRef, old: Id, new: Id) {
let DefinitionRef::Local(id) = reference;
if *id == old {
*id = new;
}
}
fn remap_type(value: &mut AnalysisType, old: Id, new: Id) {
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
if let ScalarType::Quantity(reference) = scalar {
remap_ref(reference, old, new);
}
}
fn remap_binding(value: &mut Binding, old: Id, new: Id) {
if let Binding::Expression(expression) = value {
walk_mut(expression, &mut |node| match node {
Expr::Property(id) if *id == old => *id = new,
Expr::Quantity { unit, .. } => remap_ref(unit, old, new),
Expr::Call { definition, .. } => remap_ref(definition, old, new),
Expr::AsKind { kind, .. } => remap_ref(kind, old, new),
_ => {} // Parameter nodes and argument keys are scoped, never global.
});
}
}
fn remap_subject(subject: &mut AnalysisSubject, old: Id, new: Id) {
let (AnalysisSubject::Element(id) | AnalysisSubject::Relation(id)) = subject;
if *id == old {
*id = new;
}
}
/// Check before any mutation so a map-key collision never silently discards a binding.
pub(crate) fn check_remap(project: &Project, old: Id, new: Id) -> Result<(), AnalysisError> {
if old != new {
for element in project.elements.values() {
if let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared()
&& overrides.contains_key(&old)
&& overrides.contains_key(&new)
{
return Err(AnalysisError::new(
element.id,
"overrides",
"analysis_reference_collision",
"Reidentification would merge two distinct override keys.",
));
}
}
}
Ok(())
}
pub(crate) fn remap_project(project: &mut Project, old: Id, new: Id) {
for element in project.elements.values_mut() {
match element.shared_mut() {
Some(SharedBody::EngineeringProperty {
subject,
value_type,
display_unit,
base,
}) => {
remap_subject(subject, old, new);
remap_type(value_type, old, new);
if let Some(reference) = display_unit {
remap_ref(reference, old, new);
}
remap_binding(base, old, new);
}
Some(SharedBody::AnalysisCase {
subject, overrides, ..
}) => {
if let Some(subject) = subject {
remap_subject(subject, old, new);
}
if old != new
&& let Some(value) = overrides.remove(&old)
{
overrides.insert(new, value);
}
for value in overrides.values_mut() {
remap_binding(value, old, new);
}
}
Some(SharedBody::AnalysisCheck {
subject,
documented_constraint,
predicate,
}) => {
if let Some(subject) = subject {
remap_subject(subject, old, new);
}
if *documented_constraint == Some(old) {
*documented_constraint = Some(new);
}
remap_binding(predicate, old, new);
}
_ => {}
}
}
for definition in project.definitions.values_mut() {
match &mut definition.body {
DefinitionBody::QuantityKind(QuantityKindDefinition {
algebra: QuantityAlgebra::Affine { difference },
..
}) => remap_ref(difference, old, new),
DefinitionBody::Unit(unit) => remap_ref(&mut unit.kind, old, new),
DefinitionBody::Calculation(calculation) => {
for parameter in &mut calculation.parameters {
remap_type(&mut parameter.value_type, old, new);
}
remap_type(&mut calculation.result, old, new);
remap_binding(&mut calculation.body, old, new);
}
_ => {}
}
}
}
+146
View File
@@ -0,0 +1,146 @@
use super::*;
use crate::analysis_numeric::{NumericType, ResolvedKind, Unit};
use crate::{Id, Project, SharedBody};
use std::collections::BTreeMap;
/// Bounded local nominal origin; no rooted graph is cloned during arithmetic.
/// Full ResolvedKind equality additionally includes dimension/algebra/difference.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct KindHandle {
model: Id,
definition: Id,
}
impl KindHandle {
pub fn definition(self) -> Id {
self.definition
}
pub(crate) fn local(model: Id, definition: Id) -> Self {
Self { model, definition }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedScalar {
Boolean,
Numeric(NumericType<KindHandle>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedType {
Scalar(ResolvedScalar),
List(ResolvedScalar),
}
impl ResolvedType {
pub(crate) fn boolean() -> Self {
Self::Scalar(ResolvedScalar::Boolean)
}
pub(crate) fn numeric(value: NumericType<KindHandle>) -> Self {
Self::Scalar(ResolvedScalar::Numeric(value.normalized()))
}
}
#[derive(Debug, Clone, Copy)]
pub struct EffectiveBinding<'a> {
pub binding: &'a Binding,
pub origin: BindingOrigin,
}
/// Constructed only by admission; exact source/capture provenance stays with its owner.
/// Values from different accepted environments must not be mixed by an evaluator.
pub struct ResolvedAnalysis<'p> {
pub(super) project: &'p Project,
pub(super) kinds: BTreeMap<Id, ResolvedKind<KindHandle>>,
pub(super) units: BTreeMap<Id, Unit<KindHandle>>,
pub(super) limits: AdmissionLimits,
pub(super) usage: AdmissionUsage,
}
impl<'p> ResolvedAnalysis<'p> {
pub fn project(&self) -> &'p Project {
self.project
}
pub fn admission_usage(&self) -> AdmissionUsage {
self.usage
}
pub fn admission_limits(&self) -> AdmissionLimits {
self.limits
}
pub fn definition(&self, reference: DefinitionRef) -> Option<&'p Definition> {
self.project.definitions.get(&reference.local())
}
pub fn kind(&self, reference: DefinitionRef) -> Option<&ResolvedKind<KindHandle>> {
self.kinds.get(&reference.local())
}
pub fn unit(&self, reference: DefinitionRef) -> Option<&Unit<KindHandle>> {
self.units.get(&reference.local())
}
pub fn calculation(&self, reference: DefinitionRef) -> Option<&'p CalculationDefinition> {
match &self.definition(reference)?.body {
DefinitionBody::Calculation(value) => Some(value),
_ => None,
}
}
pub fn declared_type(&self, value: &AnalysisType) -> Option<ResolvedType> {
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
let scalar = match scalar {
ScalarType::Boolean => ResolvedScalar::Boolean,
ScalarType::Number => ResolvedScalar::Numeric(NumericType::Number),
ScalarType::Quantity(reference) => {
ResolvedScalar::Numeric(NumericType::Named(self.kind(*reference)?.clone()))
}
};
Some(match value {
AnalysisType::Scalar(_) => ResolvedType::Scalar(scalar),
AnalysisType::List(_) => ResolvedType::List(scalar),
})
}
pub fn effective_binding(
&self,
property: Id,
context: EvaluationContext,
) -> Result<EffectiveBinding<'p>, AnalysisError> {
let element = self.project.elements.get(&property).ok_or_else(|| {
AnalysisError::new(
property,
"context",
"analysis_property",
"Property does not exist.",
)
})?;
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared() else {
return Err(AnalysisError::new(
property,
"context",
"analysis_property",
"Target is not an engineering property.",
));
};
if let EvaluationContext::Case(id) = context {
let case = self.project.elements.get(&id).ok_or_else(|| {
AnalysisError::new(id, "context", "analysis_case", "Case does not exist.")
})?;
let Some(SharedBody::AnalysisCase { overrides, .. }) = case.shared() else {
return Err(AnalysisError::new(
id,
"context",
"analysis_case",
"Context is not an analysis case.",
));
};
if case.layer() != element.layer() {
return Err(AnalysisError::new(
id,
"context",
"analysis_layer",
"Case and property must share a layer.",
));
}
if let Some(binding) = overrides.get(&property) {
return Ok(EffectiveBinding {
binding,
origin: BindingOrigin::CaseOverride(id),
});
}
}
Ok(EffectiveBinding {
binding: base,
origin: BindingOrigin::ModelBaseline,
})
}
}
File diff suppressed because it is too large Load Diff
+458
View File
@@ -0,0 +1,458 @@
//! Static expression checking shares numeric operator algebra with execution.
use super::admission::Budget;
use super::*;
use crate::analysis_numeric::{NumericError, NumericType, ResolvedKind};
use crate::{Id, Layer, SharedBody};
use std::collections::BTreeMap;
pub(super) struct BindingContext<'a> {
pub owner: Id,
pub field: &'a str,
pub layer: Option<Layer>,
pub parameters: &'a BTreeMap<ParameterId, ResolvedType>,
}
pub(super) fn binding(
environment: &ResolvedAnalysis<'_>,
context: BindingContext<'_>,
value: &Binding,
expected: &ResolvedType,
budget: &mut Budget,
) -> Result<(), AnalysisError> {
if let Binding::Expression(expression) = value {
let mut checker = Checker {
environment,
owner: context.owner,
field: context.field,
layer: context.layer,
parameters: context.parameters,
budget,
};
checker.expression(expression, Some(expected))?;
}
Ok(())
}
struct Checker<'a, 'p, 'b> {
environment: &'a ResolvedAnalysis<'p>,
owner: Id,
field: &'a str,
layer: Option<Layer>,
parameters: &'a BTreeMap<ParameterId, ResolvedType>,
budget: &'b mut Budget,
}
/// One suspended expression per logical depth. No Expr is owned or cloned by
/// a frame, and every stored type/nominal handle has fixed, nonrecursive size.
struct Frame<'e, 'p> {
expression: &'e Expr,
expected: Option<ResolvedType>,
state: State<'p>,
}
enum State<'p> {
Enter,
Unary,
BinaryLeft,
BinaryRight(ResolvedType),
IfCondition,
IfTrue,
IfFalse(ResolvedType),
List {
next: usize,
},
Call {
calculation: &'p CalculationDefinition,
next: usize,
},
SumInitial,
SumValues(ResolvedType),
Power,
AsKind(ResolvedKind<KindHandle>),
}
enum Step<'e> {
Child(&'e Expr, Option<ResolvedType>),
Complete(ResolvedType),
}
impl<'p> Checker<'_, 'p, '_> {
fn error(&self, code: &str, message: impl Into<String>) -> AnalysisError {
AnalysisError::new(self.owner, self.field, code, message)
}
fn numeric(&self, value: ResolvedType) -> Result<NumericType<KindHandle>, AnalysisError> {
match value {
ResolvedType::Scalar(ResolvedScalar::Numeric(value)) => Ok(value),
_ => Err(self.error("analysis_type", "A scalar numeric operand is required.")),
}
}
fn operation(
&self,
result: Result<NumericType<KindHandle>, NumericError>,
) -> Result<ResolvedType, AnalysisError> {
result
.map(ResolvedType::numeric)
.map_err(|error| self.error("analysis_type", error.to_string()))
}
/// Explicit continuations replace Rust recursion. Width never expands the
/// frame stack: lists and calls schedule only their next ordered child.
/// At most 128 frames and one returned scalar/list type are retained. The
/// per-node/per-argument meter charges remain identical to recursive entry;
/// continuation steps do constant bounded work within those charges.
fn expression(
&mut self,
expression: &Expr,
expected: Option<&ResolvedType>,
) -> Result<ResolvedType, AnalysisError> {
let mut frames = Vec::with_capacity(MAX_EXPRESSION_DEPTH);
frames.push(Frame {
expression,
expected: expected.cloned(),
state: State::Enter,
});
let mut returned = None;
loop {
let frame = frames.last_mut().expect("active expression frame");
match self.advance(frame, returned.take())? {
Step::Child(expression, expected) => {
if frames.len() == MAX_EXPRESSION_DEPTH {
// Preserve entry charging/error precedence without ever
// allocating the rejected 129th frame.
self.budget.charge(self.owner, self.field, 1)?;
return Err(self.error(
"analysis_depth",
"Expression exceeds the logical depth limit.",
));
}
frames.push(Frame {
expression,
expected,
state: State::Enter,
});
}
Step::Complete(actual) => {
let frame = frames.pop().expect("completed expression frame");
let expected = frame.expected.as_ref();
if expected.is_some_and(|expected| expected != &actual) {
return Err(self.error(
"analysis_type",
format!("Expected {expected:?}; expression has {actual:?}."),
));
}
if frames.is_empty() {
return Ok(actual);
}
returned = Some(actual);
}
}
}
}
fn advance<'e>(
&mut self,
frame: &mut Frame<'e, 'p>,
returned: Option<ResolvedType>,
) -> Result<Step<'e>, AnalysisError> {
let state = std::mem::replace(&mut frame.state, State::Enter);
if let State::Enter = state {
self.budget.charge(self.owner, self.field, 1)?;
return self.enter(frame);
}
let child = returned.expect("continuation follows a completed child");
match state {
State::Enter => unreachable!("handled before consuming child"),
State::Unary => {
let Expr::Unary { op, .. } = frame.expression else {
unreachable!()
};
Ok(Step::Complete(match op {
UnaryOp::Not => ResolvedType::boolean(),
UnaryOp::Negate | UnaryOp::Absolute => {
self.operation(self.numeric(child)?.unary_result())?
}
}))
}
State::BinaryLeft => {
let Expr::Binary { right, .. } = frame.expression else {
unreachable!()
};
frame.state = State::BinaryRight(child);
Ok(Step::Child(right, None))
}
State::BinaryRight(left) => {
let Expr::Binary { op, .. } = frame.expression else {
unreachable!()
};
Ok(Step::Complete(self.binary(*op, left, child)?))
}
State::IfCondition => {
let Expr::If { when_true, .. } = frame.expression else {
unreachable!()
};
frame.state = State::IfTrue;
Ok(Step::Child(when_true, frame.expected.clone()))
}
State::IfTrue => {
let Expr::If { when_false, .. } = frame.expression else {
unreachable!()
};
frame.state = State::IfFalse(child.clone());
Ok(Step::Child(when_false, Some(child)))
}
State::IfFalse(value) => Ok(Step::Complete(value)),
State::List { next } => {
let ResolvedType::Scalar(scalar) = child else {
return Err(self.error(
"analysis_list",
"Lists are flat; nested lists are not admitted.",
));
};
let Expr::List(items) = frame.expression else {
unreachable!()
};
if let Some(item) = items.get(next) {
frame.state = State::List { next: next + 1 };
Ok(Step::Child(item, Some(ResolvedType::Scalar(scalar))))
} else {
Ok(Step::Complete(ResolvedType::List(scalar)))
}
}
State::Call { calculation, next } => self.call_argument(frame, calculation, next),
State::SumInitial => {
let numeric = self.numeric(child.clone())?;
self.operation(numeric.unary_result())?;
let ResolvedType::Scalar(scalar) = &child else {
unreachable!()
};
let expected = ResolvedType::List(scalar.clone());
let Expr::Sum { values, .. } = frame.expression else {
unreachable!()
};
frame.state = State::SumValues(child);
Ok(Step::Child(values, Some(expected)))
}
State::SumValues(initial) => {
let numeric = self.numeric(initial.clone())?;
self.operation(numeric.additive_result(&numeric, false))?;
Ok(Step::Complete(initial))
}
State::Power => {
let Expr::Power { exponent, .. } = frame.expression else {
unreachable!()
};
Ok(Step::Complete(
self.operation(self.numeric(child)?.power_result(*exponent))?,
))
}
State::AsKind(kind) => Ok(Step::Complete(
self.operation(self.numeric(child)?.assertion_result(&kind))?,
)),
}
}
fn enter<'e>(&mut self, frame: &mut Frame<'e, 'p>) -> Result<Step<'e>, AnalysisError> {
let actual = match frame.expression {
Expr::Boolean(_) => ResolvedType::boolean(),
Expr::Number(_) => ResolvedType::numeric(NumericType::Number),
Expr::Quantity { unit, .. } => {
let unit = self.environment.unit(*unit).ok_or_else(|| {
self.error(
"analysis_unit",
"Quantity literal must reference an existing unit definition.",
)
})?;
ResolvedType::numeric(NumericType::Named(unit.kind().clone()))
}
Expr::Property(id) => {
let layer = self.layer.ok_or_else(|| {
self.error(
"analysis_purity",
"Reusable calculations cannot capture project properties.",
)
})?;
let element =
self.environment.project.elements.get(id).ok_or_else(|| {
self.error("analysis_property", "Property does not exist.")
})?;
let Some(SharedBody::EngineeringProperty { value_type, .. }) = element.shared()
else {
return Err(self.error(
"analysis_property",
"Reference must target an engineering property.",
));
};
if element.layer() != layer {
return Err(self.error(
"analysis_layer",
"Property references must stay in the same layer.",
));
}
self.environment.declared_type(value_type).ok_or_else(|| {
self.error("analysis_kind", "Declared property kind does not resolve.")
})?
}
Expr::Parameter(id) => self.parameters.get(id).cloned().ok_or_else(|| {
self.error(
"analysis_parameter",
"Parameter does not belong to this calculation definition.",
)
})?,
Expr::List(items) => {
if items.len() > MAX_LIST_LENGTH {
return Err(
self.error("analysis_list", "List exceeds the flat-list length limit.")
);
}
let scalar = match &frame.expected {
Some(ResolvedType::List(value)) => Some(value.clone()),
_ => None,
};
if let Some(item) = items.first() {
frame.state = State::List { next: 1 };
return Ok(Step::Child(item, scalar.map(ResolvedType::Scalar)));
}
ResolvedType::List(scalar.ok_or_else(|| {
self.error(
"analysis_list",
"An empty list requires an explicit surrounding list type.",
)
})?)
}
Expr::Unary { op, operand } => {
frame.state = State::Unary;
let expected = (*op == UnaryOp::Not).then(ResolvedType::boolean);
return Ok(Step::Child(operand, expected));
}
Expr::Binary { left, .. } => {
frame.state = State::BinaryLeft;
return Ok(Step::Child(left, None));
}
Expr::If { condition, .. } => {
frame.state = State::IfCondition;
return Ok(Step::Child(condition, Some(ResolvedType::boolean())));
}
Expr::Call {
definition,
arguments,
} => {
let calculation = self.environment.calculation(*definition).ok_or_else(|| {
self.error(
"analysis_calculation",
"Call must reference a calculation definition.",
)
})?;
if arguments.len() != calculation.parameters.len() {
return Err(self.error("analysis_arguments", "Call arguments must bind every declared parameter exactly once, by its scoped identity."));
}
// Charge the complete key scan before any parameter lookup.
self.budget
.charge(self.owner, self.field, calculation.parameters.len() as u64)?;
if calculation
.parameters
.iter()
.any(|parameter| !arguments.contains_key(&parameter.id))
{
return Err(self.error("analysis_arguments", "Call arguments must bind every declared parameter exactly once, by its scoped identity."));
}
return self.call_argument(frame, calculation, 0);
}
Expr::Sum { initial, .. } => {
frame.state = State::SumInitial;
return Ok(Step::Child(initial, None));
}
Expr::Power { base, .. } => {
frame.state = State::Power;
return Ok(Step::Child(base, None));
}
Expr::AsKind { kind, value } => {
let kind = self
.environment
.kind(*kind)
.ok_or_else(|| {
self.error(
"analysis_kind",
"Kind assertion must reference a quantity kind.",
)
})?
.clone();
frame.state = State::AsKind(kind);
return Ok(Step::Child(value, None));
}
};
Ok(Step::Complete(actual))
}
/// Arguments follow parameter declaration order, not UUID map order. Retain
/// the resolved calculation by reference and schedule one argument at a time.
fn call_argument<'e>(
&mut self,
frame: &mut Frame<'e, 'p>,
calculation: &'p CalculationDefinition,
next: usize,
) -> Result<Step<'e>, AnalysisError> {
if let Some(parameter) = calculation.parameters.get(next) {
self.budget.charge(self.owner, self.field, 1)?;
let expected = self
.environment
.declared_type(&parameter.value_type)
.ok_or_else(|| self.error("analysis_kind", "Parameter kind does not resolve."))?;
let Expr::Call { arguments, .. } = frame.expression else {
unreachable!()
};
frame.state = State::Call {
calculation,
next: next + 1,
};
Ok(Step::Child(&arguments[&parameter.id], Some(expected)))
} else {
let result = self
.environment
.declared_type(&calculation.result)
.ok_or_else(|| {
self.error("analysis_kind", "Calculation result kind does not resolve.")
})?;
Ok(Step::Complete(result))
}
}
fn binary(
&self,
op: BinaryOp,
left: ResolvedType,
right: ResolvedType,
) -> Result<ResolvedType, AnalysisError> {
match op {
BinaryOp::And | BinaryOp::Or => {
if left != ResolvedType::boolean() || right != ResolvedType::boolean() {
return Err(self.error("analysis_type", "Boolean operands are required."));
}
Ok(ResolvedType::boolean())
}
BinaryOp::Equal | BinaryOp::NotEqual
if left == ResolvedType::boolean() && right == ResolvedType::boolean() =>
{
Ok(ResolvedType::boolean())
}
BinaryOp::Equal
| BinaryOp::NotEqual
| BinaryOp::Less
| BinaryOp::LessOrEqual
| BinaryOp::Greater
| BinaryOp::GreaterOrEqual => {
self.numeric(left)?
.comparison_compatible(&self.numeric(right)?)
.map_err(|error| self.error("analysis_type", error.to_string()))?;
Ok(ResolvedType::boolean())
}
BinaryOp::Add | BinaryOp::Subtract => self.operation(
self.numeric(left)?
.additive_result(&self.numeric(right)?, op == BinaryOp::Subtract),
),
BinaryOp::Multiply | BinaryOp::Divide => self.operation(
self.numeric(left)?
.multiplicative_result(&self.numeric(right)?, op == BinaryOp::Divide),
),
}
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Persistent analysis vocabulary. This is part of Project, not an evaluation model.
use crate::Id;
use crate::analysis_numeric::{Dimension, Exact};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Only real local definitions are admitted in schema 8's first environment.
/// Imported references require the separately implemented pinned package catalog.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DefinitionRef {
Local(Id),
}
impl DefinitionRef {
pub fn local(self) -> Id {
match self {
Self::Local(id) => id,
}
}
}
/// Qualified by its owning CalculationDefinition; never a global model identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ParameterId(pub Id);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Definition {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub body: DefinitionBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DefinitionBody {
QuantityKind(QuantityKindDefinition),
Unit(UnitDefinition),
Calculation(CalculationDefinition),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QuantityKindDefinition {
pub dimension: Dimension,
pub algebra: QuantityAlgebra,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum QuantityAlgebra {
Linear,
Affine { difference: DefinitionRef },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UnitDefinition {
pub kind: DefinitionRef,
pub scale: Exact,
pub offset: Exact,
pub symbol: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ScalarType {
Boolean,
Number,
Quantity(DefinitionRef),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum AnalysisType {
Scalar(ScalarType),
List(ScalarType),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Parameter {
pub id: ParameterId,
pub name: String,
pub value_type: AnalysisType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CalculationDefinition {
pub parameters: Vec<Parameter>,
pub result: AnalysisType,
pub body: Binding,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Binding {
Unbound,
Expression(Expr),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum UnaryOp {
Negate,
Absolute,
Not,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BinaryOp {
Add,
Subtract,
Multiply,
Divide,
Equal,
NotEqual,
Less,
LessOrEqual,
Greater,
GreaterOrEqual,
And,
Or,
}
pub enum Expr {
Boolean(bool),
Number(Exact),
Quantity {
magnitude: Exact,
unit: DefinitionRef,
},
Property(Id),
Parameter(ParameterId),
List(Vec<Expr>),
Unary {
op: UnaryOp,
operand: Box<Expr>,
},
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
If {
condition: Box<Expr>,
when_true: Box<Expr>,
when_false: Box<Expr>,
},
Call {
definition: DefinitionRef,
arguments: BTreeMap<ParameterId, Expr>,
},
Sum {
values: Box<Expr>,
initial: Box<Expr>,
},
Power {
base: Box<Expr>,
exponent: i8,
},
AsKind {
kind: DefinitionRef,
value: Box<Expr>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum AnalysisSubject {
Element(Id),
Relation(Id),
}
impl AnalysisSubject {
pub fn id(self) -> Id {
match self {
Self::Element(id) | Self::Relation(id) => id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BaselineBase {
ModelBaseline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum EvaluationContext {
ModelBaseline,
Case(Id),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum BindingOrigin {
ModelBaseline,
CaseOverride(Id),
}
/// Located structural/static admission failure, not a false or failed evaluation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(deny_unknown_fields)]
#[error("{code} at {object}.{field}: {message}")]
pub struct AnalysisError {
pub object: Id,
pub field: String,
pub code: String,
pub message: String,
}
impl AnalysisError {
pub(crate) fn new(
object: Id,
field: impl Into<String>,
code: &str,
message: impl Into<String>,
) -> Self {
Self {
object,
field: field.into(),
code: code.into(),
message: message.into(),
}
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Exact numeric foundation for analysis; no model, expression, or package resolver.
//!
//! Values use reduced rational coordinates. All nontrivial arithmetic takes one
//! request-local [WorkMeter]. The future evaluator must also charge *expanded*
//! expression/call/property/list/target visits and retain the same logical walk on
//! cache hits. Stored AST size is not a dynamic execution limit.
//!
//! Approximate engineering functions/irrational unit scales require a subsequent
//! explicit numeric profile; rationals do not implement degree/radian conversion
//! or transcendental mathematics.
mod dimension;
mod exact;
mod meter;
mod quantity;
pub use dimension::{Dimension, DimensionAxis};
pub use exact::{Exact, ExactWire};
pub use meter::{
EvaluationStep, NumericOperation, WorkExhaustion, WorkLimits, WorkMeter, WorkResource,
WorkSite, WorkUsage,
};
pub use quantity::{NumericType, NumericValue, ResolvedKind, Unit};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Profile identity includes the accounting rules, not crate-internal timing.
pub const WORK_PROFILE: &str = "Q1-logical-work-1";
pub const MAX_COMPONENT_BITS: u64 = 4096;
/// Cross products require 8192 bits; their sum can require one additional bit.
pub const MAX_INTERMEDIATE_BITS: u64 = 8193;
pub const MAX_COMPONENT_DIGITS: usize = 1234;
pub const MAX_DECIMAL_EXPONENT: i32 = 128;
pub const MAX_INTEGER_POWER: i8 = 16;
/// Includes ratio punctuation/whitespace without permitting unbounded scanning.
pub const MAX_LITERAL_BYTES: usize = 2 * MAX_COMPONENT_DIGITS + 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NumberComponent {
Numerator,
Denominator,
}
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
pub enum NumericLimit {
#[error("request work exhausted: {0}")]
Work(WorkExhaustion),
#[error("{component:?} uses {bits} bits, above the {limit}-bit {stage} limit")]
Bits {
component: NumberComponent,
bits: u64,
limit: u64,
stage: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
pub enum NumericError {
#[error("numeric limit: {0}")]
Limit(NumericLimit),
#[error("invalid numeric literal")]
InvalidLiteral,
#[error("numeric literal exceeds its digit or byte limit")]
LiteralTooLong,
#[error("decimal exponent is outside -128 through 128")]
DecimalExponent,
#[error("wire rational is not in canonical reduced form")]
NonCanonicalWire,
#[error("rational denominator is zero")]
ZeroDenominator,
#[error("division by zero")]
DivisionByZero,
#[error("zero to the power zero is undefined in this profile")]
ZeroPowerZero,
#[error("integer power is outside -16 through 16")]
IntegerPower,
#[error("dimension exponent at {axis:?} is outside -32 through 32: {value}")]
DimensionExponent { axis: DimensionAxis, value: i32 },
#[error("unit scale must be strictly positive")]
UnitScale,
#[error("a linear unit must have zero offset")]
LinearOffset,
#[error("an affine point must have a distinct linear difference kind")]
DifferenceKind,
#[error("numeric kinds or dimensions are incompatible")]
IncompatibleTypes,
#[error("operation is not defined for affine points")]
AffineOperation,
#[error("AsKind requires an anonymous matching dimension and a linear target")]
KindAssertion,
#[error("request work limits exceed the provisional reference ceilings")]
InvalidWorkLimits,
}
impl NumericError {
pub fn is_terminal_work_exhaustion(&self) -> bool {
matches!(self, Self::Limit(NumericLimit::Work(_)))
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,107 @@
use super::NumericError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DimensionAxis {
Length,
Mass,
Time,
ElectricCurrent,
Temperature,
Amount,
LuminousIntensity,
}
impl DimensionAxis {
pub const ALL: [Self; 7] = [
Self::Length,
Self::Mass,
Self::Time,
Self::ElectricCurrent,
Self::Temperature,
Self::Amount,
Self::LuminousIntensity,
];
}
/// SI base-dimension vector in DimensionAxis::ALL order, each entry -32..=32.
/// This is a dimension, never a nominal quantity-kind compatibility proof.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "[i16; 7]", into = "[i16; 7]")]
pub struct Dimension([i16; 7]);
impl Dimension {
pub const ZERO: Self = Self([0; 7]);
pub fn new(exponents: [i16; 7]) -> Result<Self, NumericError> {
for (index, value) in exponents.iter().copied().enumerate() {
if !(-32..=32).contains(&value) {
return Err(NumericError::DimensionExponent {
axis: DimensionAxis::ALL[index],
value: i32::from(value),
});
}
}
Ok(Self(exponents))
}
pub fn exponents(self) -> [i16; 7] {
self.0
}
pub fn is_dimensionless(self) -> bool {
self == Self::ZERO
}
pub fn checked_product(self, rhs: Self) -> Result<Self, NumericError> {
self.combine(rhs, 1)
}
pub fn checked_quotient(self, rhs: Self) -> Result<Self, NumericError> {
self.combine(rhs, -1)
}
pub fn checked_power(self, exponent: i8) -> Result<Self, NumericError> {
if !(-super::MAX_INTEGER_POWER..=super::MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
let mut output = [0; 7];
for (index, value) in self.0.iter().enumerate() {
output[index] = Self::checked_exponent(index, i32::from(*value) * i32::from(exponent))?;
}
Ok(Self(output))
}
fn combine(self, rhs: Self, sign: i32) -> Result<Self, NumericError> {
let mut output = [0; 7];
for (index, value) in self.0.iter().enumerate() {
output[index] =
Self::checked_exponent(index, i32::from(*value) + sign * i32::from(rhs.0[index]))?;
}
Ok(Self(output))
}
fn checked_exponent(index: usize, value: i32) -> Result<i16, NumericError> {
if !(-32..=32).contains(&value) {
return Err(NumericError::DimensionExponent {
axis: DimensionAxis::ALL[index],
value,
});
}
Ok(value as i16)
}
}
impl TryFrom<[i16; 7]> for Dimension {
type Error = NumericError;
fn try_from(value: [i16; 7]) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<Dimension> for [i16; 7] {
fn from(value: Dimension) -> Self {
value.0
}
}
@@ -0,0 +1,469 @@
use super::meter::{normalization_cost, parse_cost, product_cost, sum_cost, words};
use super::{
MAX_COMPONENT_BITS, MAX_COMPONENT_DIGITS, MAX_DECIMAL_EXPONENT, MAX_INTEGER_POWER,
MAX_INTERMEDIATE_BITS, MAX_LITERAL_BYTES, NumberComponent, NumericError, NumericLimit,
NumericOperation, WorkLimits, WorkMeter,
};
use num_bigint::BigInt;
use num_integer::Integer;
use num_traits::{One, Signed, Zero};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
/// The interchange representation deliberately does not use num-bigint's serde
/// format or JSON numeric values. Duplicate/missing/unknown fields reject.
/// Its derived decoder allocates String fields before numeric validation; a
/// transport/document adapter must bound the encoded envelope and decoded text.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExactWire {
pub numerator: String,
pub denominator: String,
}
/// Reduced rational with positive denominator and <=4096-bit components.
///
/// There are no unmetered arithmetic/ordering traits. Eq is structural equality
/// of already normalized bounded values, not an expression comparison API.
/// Standalone serde admission bounds the subsequent BigInt validation, not the
/// preceding String decoding. Document adapters must bound encoded/decoded input
/// and aggregate admission work. Evaluation uses one explicit WorkMeter.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "ExactWire", into = "ExactWire")]
pub struct Exact {
numerator: BigInt,
denominator: BigInt,
}
impl Exact {
pub fn from_integer(value: i64) -> Self {
Self {
numerator: BigInt::from(value),
denominator: BigInt::one(),
}
}
pub fn is_zero(&self) -> bool {
self.numerator.is_zero()
}
pub fn is_positive(&self) -> bool {
self.numerator.is_positive()
}
pub fn component_bits(&self) -> (u64, u64) {
(self.numerator.bits(), self.denominator.bits())
}
pub fn to_wire(&self) -> ExactWire {
ExactWire {
numerator: self.numerator.to_string(),
denominator: self.denominator.to_string(),
}
}
/// Canonical *literal* spelling, not a generic expression formatter.
/// Negative literal nodes use ratio(-n,d), even when d=1. A leading minus
/// belongs to the future expression parser's UnaryNegate node instead.
pub fn canonical_literal(&self) -> String {
if self.denominator.is_one() && !self.numerator.is_negative() {
self.numerator.to_string()
} else {
format!("ratio({}, {})", self.numerator, self.denominator)
}
}
pub fn from_wire(wire: &ExactWire, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let n_digits = wire_digits(&wire.numerator, true)?;
let d_digits = wire_digits(&wire.denominator, false)?;
let n_bound = decimal_bits(n_digits.len());
let d_bound = decimal_bits(d_digits.len());
meter.numeric(
NumericOperation::ParseWire,
parse_cost(n_digits.len(), n_bound)
+ parse_cost(d_digits.len(), d_bound)
+ normalization_cost(n_bound, d_bound),
)?;
let numerator = parse_integer(&wire.numerator)?;
let denominator = parse_integer(&wire.denominator)?;
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "wire")?;
if denominator.is_zero() {
return Err(NumericError::ZeroDenominator);
}
if !numerator.gcd(&denominator).is_one() {
return Err(NumericError::NonCanonicalWire);
}
Ok(Self {
numerator,
denominator,
})
}
/// Reads one unsigned decimal/integer atom or a ratio(n,d) literal only.
/// It does not parse expressions, unary operators, units, calls or names.
/// Source ratios/decimals may reduce; wire values must already be canonical.
pub fn parse_literal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
if source.len() > MAX_LITERAL_BYTES {
return Err(NumericError::LiteralTooLong);
}
let source = trim_ascii(source);
if let Some(body) = source.strip_prefix("ratio(") {
let body = body.strip_suffix(')').ok_or(NumericError::InvalidLiteral)?;
let (n, d) = body.split_once(',').ok_or(NumericError::InvalidLiteral)?;
return Self::parse_ratio(trim_ascii(n), trim_ascii(d), meter);
}
Self::parse_decimal(source, meter)
}
fn parse_ratio(n: &str, d: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let n_digits = source_integer_digits(n, true)?;
let d_digits = source_integer_digits(d, false)?;
let n_bound = decimal_bits(n_digits.len());
let d_bound = decimal_bits(d_digits.len());
Self::check_estimates(n_bound, d_bound)?;
meter.numeric(
NumericOperation::ParseLiteral,
parse_cost(n_digits.len(), n_bound)
+ parse_cost(d_digits.len(), d_bound)
+ normalization_cost(n_bound, d_bound),
)?;
Self::normalize_precharged(parse_integer(n)?, parse_integer(d)?)
}
fn parse_decimal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let mut parts = source.split(['e', 'E']);
let mantissa = parts.next().ok_or(NumericError::InvalidLiteral)?;
let exponent = match parts.next() {
Some(text) => parse_exponent(text)?,
None => 0,
};
if parts.next().is_some() {
return Err(NumericError::InvalidLiteral);
}
let (whole, fraction) = match mantissa.split_once('.') {
Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction),
Some(_) => return Err(NumericError::InvalidLiteral),
None => (mantissa, ""),
};
source_integer_digits(whole, false)?;
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
let digit_count = whole.len() + fraction.len();
if digit_count > MAX_COMPONENT_DIGITS {
return Err(NumericError::LiteralTooLong);
}
let adjustment = exponent - fraction.len() as i32;
let power = adjustment.unsigned_abs();
let mantissa_bound = decimal_bits(digit_count);
let scale_bound = decimal_bits(power as usize + 1);
let (n_bound, d_bound) = if adjustment >= 0 {
(mantissa_bound + scale_bound, 1)
} else {
(mantissa_bound, scale_bound)
};
Self::check_estimates(n_bound, d_bound)?;
// Fixed logical allowance for constructing 10^power; independent of
// num-bigint's selected multiplication/exponentiation implementation.
let power_steps = 1 + 2 * u64::from(u32::BITS - power.leading_zeros());
meter.numeric(
NumericOperation::ParseLiteral,
parse_cost(digit_count, mantissa_bound)
+ power_steps * product_cost(scale_bound, scale_bound)
+ product_cost(mantissa_bound, scale_bound)
+ normalization_cost(n_bound, d_bound),
)?;
let digits = format!("{whole}{fraction}");
let mut numerator = parse_integer(&digits)?;
let scale = BigInt::from(10_u8).pow(power);
let denominator = if adjustment >= 0 {
numerator *= scale;
BigInt::one()
} else {
scale
};
Self::normalize_precharged(numerator, denominator)
}
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
self.add_or_subtract(rhs, false, meter)
}
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
self.add_or_subtract(rhs, true, meter)
}
fn add_or_subtract(
&self,
rhs: &Self,
subtract: bool,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
let left_bound = a + d;
let right_bound = c + b;
let n_bound = left_bound.max(right_bound) + 1;
let d_bound = b + d;
Self::check_estimates(n_bound, d_bound)?;
meter.numeric(
if subtract {
NumericOperation::Subtract
} else {
NumericOperation::Add
},
product_cost(a, d)
+ product_cost(c, b)
+ sum_cost(left_bound, right_bound)
+ product_cost(b, d)
+ normalization_cost(n_bound, d_bound),
)?;
let left = &self.numerator * &rhs.denominator;
let right = &rhs.numerator * &self.denominator;
let numerator = if subtract { left - right } else { left + right };
let denominator = &self.denominator * &rhs.denominator;
Self::normalize_precharged(numerator, denominator)
}
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates(a + c, b + d)?;
meter.numeric(
NumericOperation::Multiply,
product_cost(a, c) + product_cost(b, d) + normalization_cost(a + c, b + d),
)?;
Self::normalize_precharged(
&self.numerator * &rhs.numerator,
&self.denominator * &rhs.denominator,
)
}
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates(a + d, b + c)?;
meter.numeric(
NumericOperation::Divide,
product_cost(a, d) + product_cost(b, c) + normalization_cost(a + d, b + c),
)?;
if rhs.is_zero() {
return Err(NumericError::DivisionByZero);
}
Self::normalize_precharged(
&self.numerator * &rhs.denominator,
&self.denominator * &rhs.numerator,
)
}
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates((a + d).max(c + b), 1)?;
meter.numeric(
NumericOperation::Compare,
product_cost(a, d) + product_cost(c, b) + sum_cost(a + d, c + b),
)?;
Ok((&self.numerator * &rhs.denominator).cmp(&(&rhs.numerator * &self.denominator)))
}
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Negate, self.copy_cost())?;
Ok(Self {
numerator: -&self.numerator,
denominator: self.denominator.clone(),
})
}
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Absolute, self.copy_cost())?;
Ok(Self {
numerator: self.numerator.abs(),
denominator: self.denominator.clone(),
})
}
pub fn reciprocal(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Reciprocal, self.copy_cost())?;
if self.is_zero() {
return Err(NumericError::DivisionByZero);
}
Ok(Self {
numerator: if self.numerator.is_negative() {
-&self.denominator
} else {
self.denominator.clone()
},
denominator: self.numerator.abs(),
})
}
/// Fixed left-to-right repeated multiplication, at most 16 multiplications.
/// Each reduced stage obeys the same component bound; no large power is
/// allocated and subsequently discarded after discovering it is oversized.
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Power, self.copy_cost())?;
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
if self.is_zero() && exponent == 0 {
return Err(NumericError::ZeroPowerZero);
}
let factor = if exponent < 0 {
self.reciprocal(meter)?
} else {
self.clone()
};
let mut value = Self::from_integer(1);
for _ in 0..exponent.unsigned_abs() {
value = value.multiply(&factor, meter)?;
}
Ok(value)
}
pub(super) fn copy_cost(&self) -> u64 {
let (n, d) = self.component_bits();
1 + words(n) + words(d)
}
fn normalize_precharged(
mut numerator: BigInt,
mut denominator: BigInt,
) -> Result<Self, NumericError> {
Self::check_bits(
&numerator,
&denominator,
MAX_INTERMEDIATE_BITS,
"intermediate",
)?;
if denominator.is_zero() {
return Err(NumericError::ZeroDenominator);
}
if denominator.is_negative() {
numerator = -numerator;
denominator = -denominator;
}
let gcd = numerator.gcd(&denominator);
numerator /= &gcd;
denominator /= gcd;
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "reduced")?;
Ok(Self {
numerator,
denominator,
})
}
fn check_estimates(n: u64, d: u64) -> Result<(), NumericError> {
Self::check_bit_count(
n,
NumberComponent::Numerator,
MAX_INTERMEDIATE_BITS,
"predicted",
)?;
Self::check_bit_count(
d,
NumberComponent::Denominator,
MAX_INTERMEDIATE_BITS,
"predicted",
)
}
fn check_bits(n: &BigInt, d: &BigInt, limit: u64, stage: &str) -> Result<(), NumericError> {
Self::check_bit_count(n.bits(), NumberComponent::Numerator, limit, stage)?;
Self::check_bit_count(d.bits(), NumberComponent::Denominator, limit, stage)
}
fn check_bit_count(
bits: u64,
component: NumberComponent,
limit: u64,
stage: &str,
) -> Result<(), NumericError> {
if bits > limit {
return Err(NumericError::Limit(NumericLimit::Bits {
component,
bits,
limit,
stage: stage.to_owned(),
}));
}
Ok(())
}
}
impl TryFrom<ExactWire> for Exact {
type Error = NumericError;
fn try_from(value: ExactWire) -> Result<Self, Self::Error> {
// BigInt validation is bounded after ExactWire strings have decoded.
// The adapter owns envelope/text/aggregate admission bounds and must not
// reset evaluation meters.
Self::from_wire(&value, &mut WorkMeter::new(WorkLimits::REFERENCE))
}
}
impl From<Exact> for ExactWire {
fn from(value: Exact) -> Self {
value.to_wire()
}
}
fn trim_ascii(text: &str) -> &str {
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
}
fn source_integer_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
let digits = if signed {
text.strip_prefix('-').unwrap_or(text)
} else {
text
};
if digits.len() > MAX_COMPONENT_DIGITS {
return Err(NumericError::LiteralTooLong);
}
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
Ok(digits)
}
fn wire_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
let digits = source_integer_digits(text, signed).map_err(|error| match error {
NumericError::LiteralTooLong => error,
_ => NumericError::NonCanonicalWire,
})?;
if (digits.len() > 1 && digits.starts_with('0')) || (text.starts_with('-') && digits == "0") {
return Err(NumericError::NonCanonicalWire);
}
Ok(digits)
}
fn parse_integer(text: &str) -> Result<BigInt, NumericError> {
BigInt::parse_bytes(text.as_bytes(), 10).ok_or(NumericError::InvalidLiteral)
}
fn parse_exponent(text: &str) -> Result<i32, NumericError> {
let (negative, digits) = if let Some(digits) = text.strip_prefix('-') {
(true, digits)
} else {
(false, text.strip_prefix('+').unwrap_or(text))
};
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
let mut value = 0_i32;
for digit in digits.bytes() {
value = value * 10 + i32::from(digit - b'0');
if value > MAX_DECIMAL_EXPONENT {
return Err(NumericError::DecimalExponent);
}
}
Ok(if negative { -value } else { value })
}
fn decimal_bits(digits: usize) -> u64 {
// 3.322 exceeds log2(10); dimensions are bounded before this conversion.
(digits as u64 * 3322).div_ceil(1000).max(1)
}
@@ -0,0 +1,32 @@
[
{
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"expression": {
"Number": {
"denominator": "1",
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
}
},
"name": "ten_to_300"
},
{
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
"expression": {
"Number": {
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
"numerator": "1"
}
},
"name": "one_over_two_to_4095"
},
{
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
"expression": {
"Number": {
"denominator": "1",
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
}
},
"name": "negative_4096_bit_bound"
}
]
@@ -0,0 +1,240 @@
use super::{NumericError, NumericLimit};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Every event is charged once per dynamic occurrence, before entering it.
///
/// A Call expression therefore charges Expression and CalculationEntry; a
/// Property read charges Expression and PropertyBinding. List element visits
/// are additional to evaluation of their expressions. Only executed branches
/// are charged. Targets are sorted by category (properties, then checks), then
/// stable UUID bytes, by the integrating evaluator.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EvaluationStep {
Target,
Expression,
CalculationEntry,
PropertyBinding,
ListElement,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NumericOperation {
ParseWire,
ParseLiteral,
Add,
Subtract,
Multiply,
Divide,
Compare,
Negate,
Absolute,
Reciprocal,
Power,
KindAssertion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkResource {
DynamicEvents,
NumericUnits,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkSite {
Evaluation(EvaluationStep),
Numeric(NumericOperation),
}
/// Failed reservations consume no partial charge. The first failure is latched.
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
#[error("{resource:?}: used {used}, requested {requested}, limit {limit}, at {site:?}")]
pub struct WorkExhaustion {
pub resource: WorkResource,
pub used: u64,
pub requested: u64,
pub limit: u64,
pub site: WorkSite,
}
/// Provisional calibration ceilings, not measured execution-time guarantees.
///
/// Effective limits, profile identity, capture, targets and context must all be
/// recorded in request/result/frozen identity. A caller may lower these ceilings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "WorkLimitsWire", into = "WorkLimitsWire")]
pub struct WorkLimits {
dynamic_events: u64,
numeric_units: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkLimitsWire {
dynamic_events: u64,
numeric_units: u64,
}
impl WorkLimits {
pub const REFERENCE: Self = Self {
dynamic_events: 1_000_000,
numeric_units: 50_000_000,
};
pub fn new(dynamic_events: u64, numeric_units: u64) -> Result<Self, NumericError> {
if dynamic_events > Self::REFERENCE.dynamic_events
|| numeric_units > Self::REFERENCE.numeric_units
{
return Err(NumericError::InvalidWorkLimits);
}
Ok(Self {
dynamic_events,
numeric_units,
})
}
pub fn dynamic_events(self) -> u64 {
self.dynamic_events
}
pub fn numeric_units(self) -> u64 {
self.numeric_units
}
}
impl TryFrom<WorkLimitsWire> for WorkLimits {
type Error = NumericError;
fn try_from(value: WorkLimitsWire) -> Result<Self, Self::Error> {
Self::new(value.dynamic_events, value.numeric_units)
}
}
impl From<WorkLimits> for WorkLimitsWire {
fn from(value: WorkLimits) -> Self {
Self {
dynamic_events: value.dynamic_events,
numeric_units: value.numeric_units,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkUsage {
pub dynamic_events: u64,
pub numeric_units: u64,
}
/// Intentionally neither Clone nor Default: pass the same mutable meter through
/// every target, numeric operation and expanded call in one evaluation request.
///
/// Exhaustion is terminal. Integrators must stop traversing siblings/targets and
/// mark already collected causes as incomplete. Replaying an aggregate cache
/// cost is insufficient if it changes the exact exhaustion location; initially
/// retain logical traversal even when numerical values come from a cache.
#[derive(Debug)]
pub struct WorkMeter {
limits: WorkLimits,
usage: WorkUsage,
exhausted: Option<WorkExhaustion>,
}
impl WorkMeter {
pub fn new(limits: WorkLimits) -> Self {
Self {
limits,
usage: WorkUsage::default(),
exhausted: None,
}
}
pub fn limits(&self) -> WorkLimits {
self.limits
}
pub fn usage(&self) -> WorkUsage {
self.usage
}
pub fn exhaustion(&self) -> Option<&WorkExhaustion> {
self.exhausted.as_ref()
}
pub fn ensure_active(&self) -> Result<(), NumericError> {
match &self.exhausted {
Some(failure) => Err(NumericError::Limit(NumericLimit::Work(failure.clone()))),
None => Ok(()),
}
}
pub fn visit(&mut self, step: EvaluationStep) -> Result<(), NumericError> {
self.reserve(WorkResource::DynamicEvents, 1, WorkSite::Evaluation(step))
}
pub(super) fn numeric(
&mut self,
operation: NumericOperation,
units: u64,
) -> Result<(), NumericError> {
self.reserve(
WorkResource::NumericUnits,
units,
WorkSite::Numeric(operation),
)
}
fn reserve(
&mut self,
resource: WorkResource,
requested: u64,
site: WorkSite,
) -> Result<(), NumericError> {
self.ensure_active()?;
let (used, limit) = match resource {
WorkResource::DynamicEvents => (self.usage.dynamic_events, self.limits.dynamic_events),
WorkResource::NumericUnits => (self.usage.numeric_units, self.limits.numeric_units),
};
// Subtraction is safe: only successful reservations update usage.
if requested > limit - used {
let failure = WorkExhaustion {
resource,
used,
requested,
limit,
site,
};
self.exhausted = Some(failure.clone());
return Err(NumericError::Limit(NumericLimit::Work(failure)));
}
match resource {
WorkResource::DynamicEvents => self.usage.dynamic_events += requested,
WorkResource::NumericUnits => self.usage.numeric_units += requested,
}
Ok(())
}
}
// Logical 64-bit words are platform independent. These are deterministic
// accounting weights, not a claim about num-bigint's internal algorithm/timing.
pub(super) fn words(bits: u64) -> u64 {
bits.max(1).div_ceil(64)
}
pub(super) fn product_cost(a_bits: u64, b_bits: u64) -> u64 {
1 + words(a_bits) * words(b_bits)
}
pub(super) fn sum_cost(a_bits: u64, b_bits: u64) -> u64 {
1 + words(a_bits.max(b_bits))
}
pub(super) fn normalization_cost(n_bits: u64, d_bits: u64) -> u64 {
let width = words(n_bits.max(d_bits));
// 64*w² for GCD plus 2*w² for the two quotient operations.
1 + 66 * width * width
}
pub(super) fn parse_cost(digits: usize, estimated_bits: u64) -> u64 {
1 + digits as u64 * words(estimated_bits)
}
@@ -0,0 +1,385 @@
use super::{Dimension, Exact, MAX_INTEGER_POWER, NumericError, NumericOperation, WorkMeter};
use std::cmp::Ordering;
/// K is supplied by the common resolver's verified nominal compatibility
/// relation. It is not inferred from dimension/name/symbol/archive bytes here.
/// Coordinate basis/algebra/difference-kind closure must be part of that proof.
/// Exact definition references and pins remain the caller's provenance.
/// K must be a bounded, cheap-to-clone/compare verified handle. Constructing or
/// comparing an unbounded rooted contract graph is resolver admission work and
/// must not be hidden inside K's Clone/Eq implementations. Rust trait bounds do
/// not enforce this resource contract.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedKind<K> {
key: K,
dimension: Dimension,
difference: Option<K>,
}
impl<K> ResolvedKind<K> {
pub fn linear(key: K, dimension: Dimension) -> Self {
Self {
key,
dimension,
difference: None,
}
}
pub fn key(&self) -> &K {
&self.key
}
pub fn dimension(&self) -> Dimension {
self.dimension
}
pub fn is_affine(&self) -> bool {
self.difference.is_some()
}
}
impl<K: Clone + Eq> ResolvedKind<K> {
pub fn affine(key: K, difference: &Self) -> Result<Self, NumericError> {
if difference.is_affine() || key == difference.key {
return Err(NumericError::DifferenceKind);
}
Ok(Self {
key,
dimension: difference.dimension,
difference: Some(difference.key.clone()),
})
}
pub fn difference_kind(&self) -> Option<Self> {
self.difference
.as_ref()
.map(|key| Self::linear(key.clone(), self.dimension))
}
}
/// Canonical coordinate = magnitude * scale + offset.
/// Unit symbols and display preferences are outside this numeric representation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unit<K> {
kind: ResolvedKind<K>,
scale: Exact,
offset: Exact,
}
impl<K: Clone + Eq> Unit<K> {
pub fn new(kind: ResolvedKind<K>, scale: Exact, offset: Exact) -> Result<Self, NumericError> {
if !scale.is_positive() {
return Err(NumericError::UnitScale);
}
if !kind.is_affine() && !offset.is_zero() {
return Err(NumericError::LinearOffset);
}
Ok(Self {
kind,
scale,
offset,
})
}
pub fn kind(&self) -> &ResolvedKind<K> {
&self.kind
}
pub fn scale(&self) -> &Exact {
&self.scale
}
pub fn offset(&self) -> &Exact {
&self.offset
}
/// Fixed multiply-then-add path. Each reduced arithmetic stage is bounded.
/// A mathematically cancelling wider intermediate still reports NumericLimit;
/// replacing this path with a fused operation requires a new work profile.
pub fn to_canonical(
&self,
magnitude: &Exact,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
magnitude
.multiply(&self.scale, meter)?
.add(&self.offset, meter)
}
/// Fixed subtract-then-divide path; never rounds a coordinate.
pub fn from_canonical(
&self,
coordinate: &Exact,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
coordinate
.subtract(&self.offset, meter)?
.divide(&self.scale, meter)
}
pub fn convert(
&self,
magnitude: &Exact,
target: &Self,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
meter.ensure_active()?;
if self.kind != target.kind {
return Err(NumericError::IncompatibleTypes);
}
target.from_canonical(&self.to_canonical(magnitude, meter)?, meter)
}
}
/// Internal numerical types. Boolean/list/check semantics belong to the common
/// expression evaluator. Anonymous zero dimension normalizes to Number.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NumericType<K> {
Number,
Named(ResolvedKind<K>),
Anonymous(Dimension),
}
impl<K> NumericType<K> {
pub fn dimension(&self) -> Dimension {
match self {
Self::Number => Dimension::ZERO,
Self::Named(kind) => kind.dimension,
Self::Anonymous(dimension) => *dimension,
}
}
pub fn is_affine(&self) -> bool {
matches!(self, Self::Named(kind) if kind.is_affine())
}
pub fn anonymous(dimension: Dimension) -> Self {
if dimension.is_dimensionless() {
Self::Number
} else {
Self::Anonymous(dimension)
}
}
pub fn normalized(self) -> Self {
match self {
Self::Anonymous(dimension) => Self::anonymous(dimension),
other => other,
}
}
}
impl<K: Clone + Eq> NumericType<K> {
/// Pure R6 algebra; does not evaluate coordinates or domain conditions.
pub fn additive_result(&self, rhs: &Self, subtract: bool) -> Result<Self, NumericError> {
use NumericType::{Anonymous, Named, Number};
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
match (&left, &right) {
(Number, Number) => Ok(Number),
(Anonymous(a), Anonymous(b)) if a == b => Ok(Anonymous(*a)),
(Named(a), Named(b)) => match (&a.difference, &b.difference) {
(None, None) if a == b => Ok(Named(a.clone())),
(Some(difference), Some(_)) if subtract && a == b => {
Ok(Named(ResolvedKind::linear(difference.clone(), a.dimension)))
}
(Some(difference), None) if difference == &b.key && a.dimension == b.dimension => {
Ok(Named(a.clone()))
}
(None, Some(difference))
if !subtract && difference == &a.key && a.dimension == b.dimension =>
{
Ok(Named(b.clone()))
}
_ => Err(NumericError::IncompatibleTypes),
},
_ => Err(NumericError::IncompatibleTypes),
}
}
pub fn multiplicative_result(&self, rhs: &Self, divide: bool) -> Result<Self, NumericError> {
use NumericType::Number;
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
if left.is_affine() || right.is_affine() {
return Err(NumericError::AffineOperation);
}
match (&left, &right) {
(Number, Number) => Ok(Number),
(_, Number) => Ok(left),
(Number, _) if !divide => Ok(right),
_ => Ok(Self::anonymous(if divide {
left.dimension().checked_quotient(right.dimension())?
} else {
left.dimension().checked_product(right.dimension())?
})),
}
}
pub fn unary_result(&self) -> Result<Self, NumericError> {
if self.is_affine() {
Err(NumericError::AffineOperation)
} else {
Ok(self.clone().normalized())
}
}
pub fn power_result(&self, exponent: i8) -> Result<Self, NumericError> {
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
if self.is_affine() {
return Err(NumericError::AffineOperation);
}
Ok(match exponent {
0 => Self::Number,
1 => self.clone().normalized(),
_ => Self::anonymous(self.dimension().checked_power(exponent)?),
})
}
pub fn comparison_compatible(&self, rhs: &Self) -> Result<(), NumericError> {
if self.clone().normalized() == rhs.clone().normalized() {
Ok(())
} else {
Err(NumericError::IncompatibleTypes)
}
}
pub fn assertion_result(&self, target: &ResolvedKind<K>) -> Result<Self, NumericError> {
if target.is_affine()
|| matches!(self, Self::Named(_))
|| self.dimension() != target.dimension
{
Err(NumericError::KindAssertion)
} else {
Ok(Self::Named(target.clone()))
}
}
}
/// A canonical rational coordinate with an internal numerical type.
///
/// Derived Eq compares stored representation. Formal equality/ordering must call
/// compare(), which rejects mismatched nominal kinds instead of treating them
/// as a false predicate. Public property output must still check its declared
/// type and anchor the result to that declaration's exact definition reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NumericValue<K> {
value_type: NumericType<K>,
coordinate: Exact,
}
impl<K: Clone + Eq> NumericValue<K> {
pub fn new(value_type: NumericType<K>, coordinate: Exact) -> Self {
Self {
value_type: value_type.normalized(),
coordinate,
}
}
pub fn number(coordinate: Exact) -> Self {
Self::new(NumericType::Number, coordinate)
}
pub fn from_unit(
magnitude: &Exact,
unit: &Unit<K>,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
meter.ensure_active()?;
Ok(Self::new(
NumericType::Named(unit.kind.clone()),
unit.to_canonical(magnitude, meter)?,
))
}
pub fn value_type(&self) -> &NumericType<K> {
&self.value_type
}
pub fn coordinate(&self) -> &Exact {
&self.coordinate
}
pub fn display_in(&self, unit: &Unit<K>, meter: &mut WorkMeter) -> Result<Exact, NumericError> {
meter.ensure_active()?;
if !matches!(&self.value_type, NumericType::Named(kind) if kind == &unit.kind) {
return Err(NumericError::IncompatibleTypes);
}
unit.from_canonical(&self.coordinate, meter)
}
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.additive_result(&rhs.value_type, false)?;
Ok(Self::new(
output,
self.coordinate.add(&rhs.coordinate, meter)?,
))
}
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.additive_result(&rhs.value_type, true)?;
Ok(Self::new(
output,
self.coordinate.subtract(&rhs.coordinate, meter)?,
))
}
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self
.value_type
.multiplicative_result(&rhs.value_type, false)?;
Ok(Self::new(
output,
self.coordinate.multiply(&rhs.coordinate, meter)?,
))
}
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self
.value_type
.multiplicative_result(&rhs.value_type, true)?;
Ok(Self::new(
output,
self.coordinate.divide(&rhs.coordinate, meter)?,
))
}
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.unary_result()?;
Ok(Self::new(output, self.coordinate.negated(meter)?))
}
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.unary_result()?;
Ok(Self::new(output, self.coordinate.absolute(meter)?))
}
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.power_result(exponent)?;
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
}
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
meter.ensure_active()?;
self.value_type.comparison_compatible(&rhs.value_type)?;
self.coordinate.compare(&rhs.coordinate, meter)
}
pub fn as_kind(
&self,
target: &ResolvedKind<K>,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.assertion_result(target)?;
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
Ok(Self::new(output, self.coordinate.clone()))
}
}
@@ -0,0 +1,804 @@
use super::*;
use num_bigint::BigInt;
use num_traits::One;
use std::cmp::Ordering;
fn meter() -> WorkMeter {
WorkMeter::new(WorkLimits::REFERENCE)
}
fn exact(n: impl ToString, d: impl ToString) -> Exact {
Exact::from_wire(
&ExactWire {
numerator: n.to_string(),
denominator: d.to_string(),
},
&mut meter(),
)
.unwrap()
}
fn source(text: &str) -> Exact {
Exact::parse_literal(text, &mut meter()).unwrap()
}
fn assert_fraction(value: &Exact, n: impl ToString, d: impl ToString) {
assert_eq!(
value.to_wire(),
ExactWire {
numerator: n.to_string(),
denominator: d.to_string(),
}
);
}
// Independent bounded i128 oracle. It does not call the production Exact/GCD
// implementation when deriving expected arithmetic or ordering.
fn small_fraction(mut n: i128, mut d: i128) -> (i128, i128) {
assert_ne!(d, 0);
if d < 0 {
n = -n;
d = -d;
}
let (mut a, mut b) = (n.abs(), d);
while b != 0 {
(a, b) = (b, a % b);
}
(n / a, d / a)
}
#[test]
fn arithmetic_matches_independent_small_integer_oracle() {
for a in [-5_i128, -2, 0, 1, 4] {
for b in [1_i128, 2, 3, 7] {
for c in [-5_i128, -2, 0, 1, 4] {
for d in [1_i128, 2, 3, 7] {
let (an, ad) = small_fraction(a, b);
let (bn, bd) = small_fraction(c, d);
let left = exact(an, ad);
let right = exact(bn, bd);
let mut work = meter();
let (n, den) = small_fraction(a * d + c * b, b * d);
assert_fraction(&left.add(&right, &mut work).unwrap(), n, den);
let (n, den) = small_fraction(a * d - c * b, b * d);
assert_fraction(&left.subtract(&right, &mut work).unwrap(), n, den);
let (n, den) = small_fraction(a * c, b * d);
assert_fraction(&left.multiply(&right, &mut work).unwrap(), n, den);
if c != 0 {
let (n, den) = small_fraction(a * d, b * c);
assert_fraction(&left.divide(&right, &mut work).unwrap(), n, den);
} else {
assert_eq!(
left.divide(&right, &mut work),
Err(NumericError::DivisionByZero)
);
}
assert_eq!(
left.compare(&right, &mut work).unwrap(),
(a * d).cmp(&(c * b))
);
}
}
}
}
}
#[test]
fn decimals_are_exact_and_source_ratios_reduce() {
assert_fraction(
&source("0.1").add(&source("0.2"), &mut meter()).unwrap(),
3,
10,
);
assert_fraction(&source("0.50"), 1, 2);
assert_fraction(&source("12.50e-2"), 1, 8);
assert_fraction(&source("1e+3"), 1000, 1);
assert_fraction(&source("0001.2500"), 5, 4);
assert_fraction(&source(" ratio( -6, 8 ) "), -3, 4);
assert_fraction(&source("ratio(-0, 27)"), 0, 1);
assert_eq!(source("0.5").canonical_literal(), "ratio(1, 2)");
assert_eq!(source("42").canonical_literal(), "42");
}
#[test]
fn powers_and_signs_have_explicit_domains() {
let x = exact(-2, 3);
assert_fraction(&x.power(3, &mut meter()).unwrap(), -8, 27);
assert_fraction(&x.power(-3, &mut meter()).unwrap(), -27, 8);
assert_fraction(&x.power(0, &mut meter()).unwrap(), 1, 1);
assert_fraction(&x.absolute(&mut meter()).unwrap(), 2, 3);
assert_fraction(&x.negated(&mut meter()).unwrap(), 2, 3);
assert_fraction(&x.reciprocal(&mut meter()).unwrap(), -3, 2);
let zero = Exact::from_integer(0);
assert_eq!(
zero.power(0, &mut meter()),
Err(NumericError::ZeroPowerZero)
);
assert_eq!(
zero.power(-1, &mut meter()),
Err(NumericError::DivisionByZero)
);
assert_eq!(x.power(17, &mut meter()), Err(NumericError::IntegerPower));
assert_eq!(
x.power(i8::MIN, &mut meter()),
Err(NumericError::IntegerPower)
);
}
#[test]
fn canonical_wire_rejects_both_component_noncanonical_forms() {
for (n, d) in [
("+1", "1"),
("01", "1"),
("-0", "1"),
("1", "+1"),
("1", "-1"),
("1", "01"),
("2", "4"),
("0", "3"),
("", "1"),
("1", ""),
("1.0", "1"),
("1", "1.0"),
("1e1", "1"),
("1", "1e1"),
(" 1", "1"),
("1", "1 "),
] {
assert!(
Exact::from_wire(
&ExactWire {
numerator: n.into(),
denominator: d.into()
},
&mut meter(),
)
.is_err(),
"accepted {n}/{d}"
);
}
assert_eq!(
Exact::from_wire(
&ExactWire {
numerator: "1".into(),
denominator: "0".into()
},
&mut meter()
),
Err(NumericError::ZeroDenominator)
);
assert_fraction(&exact(0, 1), 0, 1);
assert_fraction(&exact(-1, 2), -1, 2);
}
#[test]
fn serde_uses_closed_canonical_string_wire() {
let value = exact(-3, 7);
let encoded = serde_json::to_value(&value).unwrap();
assert_eq!(
encoded,
serde_json::json!({"numerator":"-3","denominator":"7"})
);
assert_eq!(serde_json::from_value::<Exact>(encoded).unwrap(), value);
for invalid in [
r#"{"numerator":1,"denominator":"2"}"#,
r#"{"numerator":"1","denominator":2}"#,
r#"{"numerator":"1","denominator":"2","other":0}"#,
r#"{"numerator":"1","numerator":"2","denominator":"3"}"#,
r#"{"numerator":"1"}"#,
r#"{"numerator":"2","denominator":"4"}"#,
] {
assert!(serde_json::from_str::<Exact>(invalid).is_err(), "{invalid}");
}
}
#[test]
fn literal_reader_does_not_fold_expression_unary_minus() {
assert_eq!(
Exact::parse_literal("-2", &mut meter()),
Err(NumericError::InvalidLiteral)
);
let negative_literal = source("ratio(-2, 1)");
assert_eq!(negative_literal.canonical_literal(), "ratio(-2, 1)");
assert_eq!(source("2").negated(&mut meter()).unwrap(), negative_literal);
// Equality of coordinates does not claim AST equality. The expression
// parser must preserve UnaryNegate independently of its operand literal.
assert_eq!(source("2").canonical_literal(), "2");
}
#[test]
fn literal_syntax_and_decimal_exponent_are_bounded() {
for invalid in [
"",
"-",
"+1",
".5",
"1.",
"1.2.3",
"1e",
"1ee2",
"1e+-2",
"1_000",
"NaN",
"Infinity",
"1/2",
"ratio(1)",
"ratio(1,2,3)",
"ratio(1,-2)",
"ratio(+1,2)",
"ratio(1,2) + 1",
"12",
] {
assert!(
Exact::parse_literal(invalid, &mut meter()).is_err(),
"{invalid}"
);
}
for invalid in ["1e129", "1e-129", "1e9999999999999999999999"] {
assert_eq!(
Exact::parse_literal(invalid, &mut meter()),
Err(NumericError::DecimalExponent)
);
}
assert!(Exact::parse_literal("1e128", &mut meter()).is_ok());
assert!(Exact::parse_literal("1e-128", &mut meter()).is_ok());
assert_eq!(
Exact::parse_literal(&"1".repeat(MAX_COMPONENT_DIGITS + 1), &mut meter()),
Err(NumericError::LiteralTooLong)
);
assert_eq!(
Exact::parse_literal(&" ".repeat(MAX_LITERAL_BYTES + 1), &mut meter()),
Err(NumericError::LiteralTooLong)
);
}
#[test]
fn full_independent_r7_packets_round_trip_as_literals_and_wire() {
let packets: serde_json::Value =
serde_json::from_str(include_str!("fixtures/r7-literal-boundaries.json")).unwrap();
for packet in packets.as_array().unwrap() {
let wire: ExactWire =
serde_json::from_value(packet["expression"]["Number"].clone()).unwrap();
let expected = Exact::from_wire(&wire, &mut meter()).unwrap();
let spelling = packet["canonical_source"].as_str().unwrap();
assert_eq!(expected.canonical_literal(), spelling);
assert_eq!(
Exact::parse_literal(spelling, &mut meter()).unwrap(),
expected
);
let serialized = serde_json::to_string(&expected).unwrap();
assert_eq!(
serde_json::from_str::<Exact>(&serialized).unwrap(),
expected
);
assert_eq!(expected.to_wire(), wire);
}
}
#[test]
fn full_8193_bit_sum_intermediate_can_reduce_to_admitted_value() {
// N=(2^4096)-1, D=N-1. gcd(N,D)=1 and D is even.
// N/D + N/D = N/(D/2), although its un-reduced numerator needs 8193 bits.
let n = (BigInt::one() << 4096_usize) - 1_u8;
let d = &n - 1_u8;
let value = exact(&n, &d);
let result = value.add(&value, &mut meter()).unwrap();
assert_fraction(&result, &n, &d / 2_u8);
}
#[test]
fn cross_products_cancel_before_reduced_component_limit() {
let n = BigInt::one() << 4095_usize;
let d = &n - 1_u8;
let left = exact(&n, &d);
let right = exact(&d, &n);
assert_fraction(&left.multiply(&right, &mut meter()).unwrap(), 1, 1);
assert_fraction(
&left
.add(&left.negated(&mut meter()).unwrap(), &mut meter())
.unwrap(),
0,
1,
);
}
#[test]
fn both_reduced_component_overflows_are_reported() {
let n = BigInt::one() << 4095_usize;
let mut work = meter();
let failure = exact(&n, 1)
.multiply(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(matches!(
failure,
NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Numerator,
bits: 4097,
..
})
));
assert!(work.exhaustion().is_none());
let failure = exact(1, &n)
.divide(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(matches!(
failure,
NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Denominator,
bits: 4097,
..
})
));
// Value overflow is a nonterminal supported-operation failure; fuel
// exhaustion, in contrast, terminates the entire request.
assert_fraction(&source("1").add(&source("1"), &mut work).unwrap(), 2, 1);
}
#[test]
fn wire_rejects_4097_bits_before_gcd_can_reduce_them() {
let oversized = (BigInt::one() << 4096_usize).to_string();
for wire in [
ExactWire {
numerator: oversized.clone(),
denominator: "1".into(),
},
ExactWire {
numerator: "1".into(),
denominator: oversized.clone(),
},
ExactWire {
numerator: oversized.clone(),
denominator: oversized,
},
] {
assert!(matches!(
Exact::from_wire(&wire, &mut meter()),
Err(NumericError::Limit(NumericLimit::Bits { stage, .. })) if stage == "wire"
));
}
}
#[test]
fn numeric_precharge_precedes_big_integer_result_allocation() {
let n = BigInt::one() << 4095_usize;
let left = exact(&n, 1);
let mut work = WorkMeter::new(WorkLimits::new(10, 0).unwrap());
let failure = left
.multiply(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage().numeric_units, 0);
assert!(matches!(
work.exhaustion().unwrap().site,
WorkSite::Numeric(NumericOperation::Multiply)
));
// With available fuel the same operation reaches the 4097-bit result check.
assert!(matches!(
left.multiply(&Exact::from_integer(2), &mut meter()),
Err(NumericError::Limit(NumericLimit::Bits { .. }))
));
let too_wide_wire = ExactWire {
numerator: (BigInt::one() << 4096_usize).to_string(),
denominator: "1".into(),
};
let mut parse_work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
assert!(
Exact::from_wire(&too_wide_wire, &mut parse_work)
.unwrap_err()
.is_terminal_work_exhaustion()
);
}
#[test]
fn provisional_limits_and_effective_limits_round_trip() {
let limits = WorkLimits::new(500, 2000).unwrap();
let encoded = serde_json::to_value(limits).unwrap();
assert_eq!(
encoded,
serde_json::json!({"dynamic_events":500,"numeric_units":2000})
);
assert_eq!(
serde_json::from_value::<WorkLimits>(encoded).unwrap(),
limits
);
assert_eq!(
WorkLimits::new(1_000_001, 0),
Err(NumericError::InvalidWorkLimits)
);
assert_eq!(
WorkLimits::new(0, 50_000_001),
Err(NumericError::InvalidWorkLimits)
);
assert!(
serde_json::from_str::<WorkLimits>(r#"{"dynamic_events":1000001,"numeric_units":0}"#)
.is_err()
);
}
#[test]
fn exact_accounting_boundary_and_terminal_failure_are_stable() {
// One-word addition: three products at 2, one sum at 2,
// normalization at 67 => 75 logical numeric units.
let mut work = WorkMeter::new(WorkLimits::new(4, 75).unwrap());
assert_fraction(&exact(1, 2).add(&exact(1, 3), &mut work).unwrap(), 5, 6);
assert_eq!(work.usage().numeric_units, 75);
assert!(work.exhaustion().is_none());
let first = Exact::from_integer(1).negated(&mut work).unwrap_err();
let at_failure = work.usage();
assert_eq!(
work.exhaustion().unwrap(),
&WorkExhaustion {
resource: WorkResource::NumericUnits,
used: 75,
requested: 3,
limit: 75,
site: WorkSite::Numeric(NumericOperation::Negate),
}
);
assert_eq!(work.visit(EvaluationStep::Target).unwrap_err(), first);
assert_eq!(
Exact::from_integer(1)
.divide(&Exact::from_integer(0), &mut work)
.unwrap_err(),
first
);
assert_eq!(
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
first
);
assert_eq!(work.usage(), at_failure);
}
#[test]
fn zero_cost_and_maximum_reservation_do_not_underflow_or_reactivate() {
let mut work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
work.numeric(NumericOperation::Compare, 0).unwrap();
let failure = work
.numeric(NumericOperation::Multiply, u64::MAX)
.unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage(), WorkUsage::default());
assert_eq!(
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
failure
);
let mut dynamic = WorkMeter::new(WorkLimits::new(0, 5).unwrap());
assert!(dynamic.visit(EvaluationStep::Target).is_err());
assert_eq!(dynamic.usage(), WorkUsage::default());
}
#[test]
fn multiple_targets_share_one_aggregate_meter() {
let mut work = WorkMeter::new(WorkLimits::new(3, 50).unwrap());
work.visit(EvaluationStep::Target).unwrap();
work.visit(EvaluationStep::Expression).unwrap();
work.visit(EvaluationStep::Target).unwrap();
let failure = work.visit(EvaluationStep::Expression).unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage().dynamic_events, 3);
}
// A direct bounded call-tree workload, not an expression interpreter/dialect.
fn expanded_calls(
depth: u8,
x: u64,
work: &mut WorkMeter,
entered: &mut u64,
) -> Result<u64, NumericError> {
work.visit(EvaluationStep::CalculationEntry)?;
*entered += 1;
if depth == 0 {
return Ok(x);
}
let left = expanded_calls(depth - 1, 2 * x, work, entered)?;
let right = expanded_calls(depth - 1, 2 * x + 1, work, entered)?;
Ok(left + right)
}
#[test]
fn c1_expanded_call_witness_terminates_at_dynamic_limit() {
let mut work = WorkMeter::new(WorkLimits::new(10_000, 0).unwrap());
let mut entered = 0;
let failure = expanded_calls(30, 0, &mut work, &mut entered).unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(entered, 10_000);
assert_eq!(work.usage().dynamic_events, 10_000);
assert_eq!(
work.exhaustion().unwrap().site,
WorkSite::Evaluation(EvaluationStep::CalculationEntry)
);
// Nearby finite control: 63 invocations, leaves 0..31, sum 496.
let mut bounded = WorkMeter::new(WorkLimits::new(63, 0).unwrap());
let mut entered = 0;
assert_eq!(
expanded_calls(5, 0, &mut bounded, &mut entered).unwrap(),
496
);
assert_eq!(entered, 63);
assert!(bounded.exhaustion().is_none());
}
fn dim(value: [i16; 7]) -> Dimension {
Dimension::new(value).unwrap()
}
fn linear(key: &str, dimension: Dimension) -> ResolvedKind<String> {
ResolvedKind::linear(key.to_owned(), dimension)
}
fn named(n: i64, kind: &ResolvedKind<String>) -> NumericValue<String> {
NumericValue::new(NumericType::Named(kind.clone()), Exact::from_integer(n))
}
#[test]
fn dimensions_use_checked_seven_axis_integer_algebra() {
let length = dim([1, 0, 0, 0, 0, 0, 0]);
let mass = dim([0, 1, 0, 0, 0, 0, 0]);
let time = dim([0, 0, 1, 0, 0, 0, 0]);
assert_eq!(
mass.checked_product(length)
.unwrap()
.checked_quotient(time.checked_power(2).unwrap())
.unwrap(),
dim([1, 1, -2, 0, 0, 0, 0])
);
assert!(time.checked_quotient(time).unwrap().is_dimensionless());
assert!(matches!(
dim([32, 0, 0, 0, 0, 0, 0]).checked_product(length),
Err(NumericError::DimensionExponent {
axis: DimensionAxis::Length,
value: 33
})
));
assert!(dim([0, 0, -32, 0, 0, 0, 0]).checked_quotient(time).is_err());
assert!(length.checked_power(i8::MIN).is_err());
assert!(Dimension::new([33, 0, 0, 0, 0, 0, 0]).is_err());
assert_eq!(
serde_json::to_value(time).unwrap(),
serde_json::json!([0, 0, 1, 0, 0, 0, 0])
);
for invalid in ["[0,0,33,0,0,0,0]", "[0,0,1]", "[0,0,1,0,0,0,0,0]"] {
assert!(serde_json::from_str::<Dimension>(invalid).is_err());
}
}
#[test]
fn duration_unit_coordinates_are_exact_and_nominal() {
let duration = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
let s = Unit::new(duration.clone(), source("1"), source("0")).unwrap();
let ms = Unit::new(duration.clone(), source("0.001"), source("0")).unwrap();
assert_fraction(&ms.convert(&source("750"), &s, &mut meter()).unwrap(), 3, 4);
let receive = NumericValue::from_unit(&source("750"), &ms, &mut meter()).unwrap();
let dispatch = NumericValue::from_unit(&source("1"), &s, &mut meter()).unwrap();
let total = receive.add(&dispatch, &mut meter()).unwrap();
assert_fraction(total.coordinate(), 7, 4);
assert_fraction(&total.display_in(&ms, &mut meter()).unwrap(), 1750, 1);
let different = Unit::new(
linear("different-kind", duration.dimension()),
source("1"),
source("0"),
)
.unwrap();
assert_eq!(
ms.convert(&source("750"), &different, &mut meter()),
Err(NumericError::IncompatibleTypes)
);
let inconsistent = Unit::new(
linear("duration", dim([1, 0, 0, 0, 0, 0, 0])),
source("1"),
source("0"),
)
.unwrap();
assert!(
ms.convert(&source("1"), &inconsistent, &mut meter())
.is_err()
);
assert!(Unit::new(duration.clone(), source("0"), source("0")).is_err());
assert!(Unit::new(duration.clone(), source("ratio(-1,1)"), source("0")).is_err());
assert_eq!(
Unit::new(duration, source("1"), source("1")).unwrap_err(),
NumericError::LinearOffset
);
}
#[test]
fn affine_point_and_difference_arithmetic_has_distinct_kinds() {
let interval = linear("temperature-difference", dim([0, 0, 0, 0, 1, 0, 0]));
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
let celsius = Unit::new(point.clone(), source("1"), source("273.15")).unwrap();
let fahrenheit = Unit::new(
point.clone(),
source("ratio(5,9)"),
source("ratio(45967,180)"),
)
.unwrap();
let kelvin_interval = Unit::new(interval.clone(), source("1"), source("0")).unwrap();
assert_fraction(
&celsius
.convert(&source("0"), &fahrenheit, &mut meter())
.unwrap(),
32,
1,
);
assert_fraction(
&fahrenheit
.convert(&source("70"), &celsius, &mut meter())
.unwrap(),
190,
9,
);
let p20 = NumericValue::from_unit(&source("20"), &celsius, &mut meter()).unwrap();
let p10 = NumericValue::from_unit(&source("10"), &celsius, &mut meter()).unwrap();
let difference = p20.subtract(&p10, &mut meter()).unwrap();
assert_eq!(
difference.value_type(),
&NumericType::Named(interval.clone())
);
assert_fraction(
&difference
.display_in(&kelvin_interval, &mut meter())
.unwrap(),
10,
1,
);
let delta5 = named(5, &interval);
let p25 = p20.add(&delta5, &mut meter()).unwrap();
assert_fraction(&p25.display_in(&celsius, &mut meter()).unwrap(), 25, 1);
assert_eq!(delta5.add(&p20, &mut meter()).unwrap(), p25);
assert!(p20.add(&p10, &mut meter()).is_err());
assert!(delta5.subtract(&p20, &mut meter()).is_err());
assert_eq!(
p20.power(1, &mut meter()),
Err(NumericError::AffineOperation)
);
assert_eq!(
p20.negated(&mut meter()),
Err(NumericError::AffineOperation)
);
assert_eq!(p20.compare(&p10, &mut meter()).unwrap(), Ordering::Greater);
assert!(p20.display_in(&kelvin_interval, &mut meter()).is_err());
assert!(ResolvedKind::affine(interval.key().clone(), &interval).is_err());
assert!(ResolvedKind::affine("other".to_owned(), &point).is_err());
}
#[test]
fn affine_conversion_rejects_a_wider_stage_even_when_the_final_value_would_fit() {
// A fused coordinate calculation 2*m-m equals m, where m=2^4095 fits.
// The fixed multiply stage produces 2^4096, so it must fail before offset
// cancellation. The inverse path likewise rejects (m-(-m))/2 at subtraction.
let large = BigInt::one() << 4095_usize;
let magnitude = exact(&large, 1);
let interval = linear("temperature-interval", dim([0, 0, 0, 0, 1, 0, 0]));
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
let unit = Unit::new(point, Exact::from_integer(2), exact(-&large, 1)).unwrap();
let mut work = meter();
for outcome in [
unit.to_canonical(&magnitude, &mut work),
unit.from_canonical(&magnitude, &mut work),
] {
assert!(matches!(
outcome,
Err(NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Numerator,
bits: 4097,
limit: MAX_COMPONENT_BITS,
..
}))
));
}
assert!(work.exhaustion().is_none());
let half = exact(&large >> 1_usize, 1);
assert_fraction(&unit.to_canonical(&half, &mut work).unwrap(), 0, 1);
assert_eq!(
unit.from_canonical(&Exact::from_integer(0), &mut work)
.unwrap(),
half
);
}
#[test]
fn r6_anonymous_compound_energy_needs_one_outer_assertion() {
let force = linear("force", dim([1, 1, -2, 0, 0, 0, 0]));
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
let a = named(3, &force)
.multiply(&named(4, &length), &mut meter())
.unwrap();
let b = named(2, &force)
.multiply(&named(7, &length), &mut meter())
.unwrap();
assert_eq!(a.value_type(), &NumericType::Anonymous(energy.dimension()));
let sum = a.add(&b, &mut meter()).unwrap();
let result = sum.as_kind(&energy, &mut meter()).unwrap();
assert_fraction(result.coordinate(), 26, 1);
assert_eq!(result.value_type(), &NumericType::Named(energy.clone()));
assert_eq!(a.compare(&b, &mut meter()).unwrap(), Ordering::Less);
assert!(result.add(&sum, &mut meter()).is_err());
assert_eq!(
result.as_kind(&energy, &mut meter()),
Err(NumericError::KindAssertion)
);
}
#[test]
fn anonymous_product_then_quotient_closes_and_preserves_scaling() {
let mass = linear("mass", dim([0, 1, 0, 0, 0, 0, 0]));
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let time = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
let momentum = linear("momentum", dim([1, 1, -1, 0, 0, 0, 0]));
let result = named(2, &mass)
.multiply(&named(3, &length), &mut meter())
.unwrap()
.divide(&named(2, &time), &mut meter())
.unwrap()
.as_kind(&momentum, &mut meter())
.unwrap();
assert_fraction(result.coordinate(), 3, 1);
let scalar = NumericValue::number(source("2"));
let scaled = scalar.multiply(&named(3, &time), &mut meter()).unwrap();
assert_eq!(scaled.value_type(), &NumericType::Named(time.clone()));
assert_fraction(scaled.coordinate(), 6, 1);
assert_eq!(
named(3, &time)
.divide(&scalar, &mut meter())
.unwrap()
.value_type(),
&NumericType::Named(time.clone())
);
assert_eq!(
scalar
.divide(&named(3, &time), &mut meter())
.unwrap()
.value_type(),
&NumericType::Anonymous(dim([0, 0, -1, 0, 0, 0, 0]))
);
}
#[test]
fn nominal_kinds_survive_comparison_but_explicit_quotients_can_erase_dimension() {
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
let torque = linear("torque", energy.dimension());
let left = named(7, &energy);
let right = named(7, &torque);
assert!(left.add(&right, &mut meter()).is_err());
assert!(left.compare(&right, &mut meter()).is_err());
let ratio = left.divide(&right, &mut meter()).unwrap();
assert_eq!(ratio.value_type(), &NumericType::Number);
assert_fraction(ratio.coordinate(), 1, 1);
let named_ratio = linear("chosen-ratio", Dimension::ZERO);
let asserted = ratio.as_kind(&named_ratio, &mut meter()).unwrap();
assert_eq!(
asserted.value_type(),
&NumericType::Named(named_ratio.clone())
);
assert!(asserted.add(&ratio, &mut meter()).is_err());
assert_eq!(
NumericValue::<String>::new(NumericType::Anonymous(Dimension::ZERO), source("1")),
NumericValue::number(source("1"))
);
}
#[test]
fn numerical_power_types_and_dimension_overflow_are_checked() {
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let value = named(2, &length);
assert_eq!(
value.power(0, &mut meter()).unwrap().value_type(),
&NumericType::Number
);
assert_eq!(
value.power(1, &mut meter()).unwrap().value_type(),
value.value_type()
);
assert_eq!(
value.power(-2, &mut meter()).unwrap().value_type(),
&NumericType::Anonymous(dim([-2, 0, 0, 0, 0, 0, 0]))
);
let large_dimension = linear("large-dimension", dim([32, 0, 0, 0, 0, 0, 0]));
assert!(
named(1, &large_dimension)
.multiply(&value, &mut meter())
.is_err()
);
assert!(named(1, &large_dimension).power(2, &mut meter()).is_err());
}
+20 -88
View File
@@ -1,6 +1,12 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
#[path = "language/semantic_source.rs"]
mod semantic_source;
mod source3_bridge;
mod raw_tokens;
// Staged syntax foundation only. Public source8 admission waits for complete
// declaration lowering, source updates and the root-owned resource gate.
#[cfg(test)]
mod analysis_source;
#[path = "migration_v2/source.rs"]
mod v2_source;
use crate::*;
@@ -60,7 +66,8 @@ impl ParseLimits {
let graph = project
.elements
.len()
.saturating_add(project.relations.len());
.saturating_add(project.relations.len())
.saturating_add(project.definitions.len());
let records = graph
.saturating_add(1)
.saturating_add(project.diagrams.len())
@@ -79,7 +86,8 @@ impl ParseLimits {
.sum::<usize>(),
)
.saturating_add(project.artifact_links.len())
.saturating_add(project.traces.len());
.saturating_add(project.traces.len())
.saturating_add(crate::analysis::nested_record_count(project));
let steps = project
.elements
.values()
@@ -145,86 +153,7 @@ pub struct Analysis {
/// Tolerant highlighting tokens cover the whole input, including incomplete
/// strings and unexpected characters. No model is created or modified.
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
let mut tokens = Vec::new();
let mut cursor = 0;
while cursor < source.len() {
let start = cursor;
let ch = source[cursor..].chars().next().expect("in bounds");
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
cursor += 1;
while source
.as_bytes()
.get(cursor)
.is_some_and(|next| matches!(*next, b' ' | b'\t' | b'\r' | b'\n'))
{
cursor += 1;
}
TokenClass::Whitespace
} else if source[cursor..].starts_with("//") {
cursor += source[cursor..].find('\n').unwrap_or(source.len() - cursor);
TokenClass::Comment
} else if ch == '"' {
cursor += 1;
let mut escaped = false;
let mut closed = false;
while cursor < source.len() {
let next = source[cursor..].chars().next().expect("in bounds");
cursor += next.len_utf8();
if escaped {
escaped = false;
} else if next == '\\' {
escaped = true;
} else if next == '"' {
closed = true;
break;
}
}
if closed && serde_json::from_str::<String>(&source[start..cursor]).is_ok() {
TokenClass::String
} else {
TokenClass::Error
}
} else if ch.is_ascii_alphabetic() || ch == '_' {
cursor += 1;
while source
.as_bytes()
.get(cursor)
.is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_')
{
cursor += 1;
}
if reserved(&source[start..cursor])
|| matches!(&source[start..cursor], "true" | "false" | "none")
{
TokenClass::Keyword
} else {
TokenClass::Identifier
}
} else if ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.') {
cursor += 1;
while source.as_bytes().get(cursor).is_some_and(|next| {
next.is_ascii_alphanumeric() || matches!(*next, b'.' | b'+' | b'-' | b'_')
}) {
cursor += 1;
}
if serde_json::from_str::<serde_json::Number>(&source[start..cursor]).is_ok() {
TokenClass::Number
} else {
TokenClass::Error
}
} else if "{}();,[]:=@".contains(ch) {
cursor += ch.len_utf8();
TokenClass::Punctuation
} else {
cursor += ch.len_utf8();
TokenClass::Error
};
tokens.push(TokenSpan {
class,
span: Span { start, end: cursor },
});
}
tokens
raw_tokens::legacy(source)
}
pub fn lint(source: &str) -> Vec<Diagnostic> {
@@ -1426,6 +1355,7 @@ impl Parser<'_> {
alias,
name,
description: String::new(),
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
@@ -1646,6 +1576,9 @@ pub(crate) fn resolve_declarations(
}
parent = Some(owner);
}
if kind.is_analysis() {
return Err(conversion_error("Analysis records require the source8 authoring implementation.".into()));
}
let mut initial = if kind == Kind::Port {
if !fields.contains_key("contract") {
return Err(conversion_error(
@@ -1824,10 +1757,7 @@ pub(crate) fn resolve_declarations(
target.nodes.insert(node.id, node);
}
if legacy {
let mut value = serde_json::to_value(&project).expect("project serialization");
value["elements"] = Value::Object(legacy_elements);
let migrated = serde_json::from_value(value)
.map_err(crate::persistence::FileError::Json)
let migrated = source3_bridge::from_scratch(project, legacy_elements)
.and_then(|old| crate::persistence::v3::migrate(old, limits))
.map_err(|error| {
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
@@ -1875,13 +1805,15 @@ pub(crate) fn resolve_declarations(
}
return Ok(migrated);
}
if matches!(project.schema_version, 4..=6) {
if matches!(project.schema_version, 4..=7) {
return (if project.schema_version == 4 {
crate::persistence::v4::finish(project, limits)
} else if project.schema_version == 5 {
crate::persistence::migrate_v5(project, limits)
} else {
} else if project.schema_version == 6 {
crate::persistence::migrate_v6(project, limits)
} else {
crate::persistence::migrate_v7(project, limits)
})
.map_err(|error| {
(
@@ -0,0 +1,115 @@
//! Unresolved typed source8 syntax, staged for the complete source adapter.
//! This module does not admit a Project or enable public source8.
mod budget;
mod cursor;
mod declarations;
mod index;
mod lex;
mod lower;
mod syntax;
mod update;
mod write;
#[cfg(test)]
mod model_oracle;
use super::Span;
use crate::analysis::{BinaryOp, UnaryOp};
use crate::analysis_numeric::Exact;
use budget::Budget;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error {
pub span: Span,
pub message: String,
}
fn error(span: Span, message: impl Into<String>) -> Error {
Error {
span,
message: message.into(),
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Reference {
pub name: String,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Argument {
pub parameter: Reference,
pub value: Expression,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Expression {
pub kind: ExpressionKind,
pub span: Span,
pub depth: usize,
pub grouped: bool,
/// Original numeric tokens, including constructor punctuation, excluding
/// trivia. The full span retains intervening comments and spelling.
pub literal: Option<LiteralSyntax>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct LiteralSyntax {
pub span: Span,
pub tokens: Vec<Span>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ExpressionKind {
Boolean(bool),
Number(Exact),
Quantity {
magnitude: Exact,
unit: Reference,
},
Property(Reference),
Parameter(Reference),
List(Vec<Expression>),
Unary {
op: UnaryOp,
operand: Box<Expression>,
},
Binary {
op: BinaryOp,
left: Box<Expression>,
right: Box<Expression>,
},
If {
condition: Box<Expression>,
when_true: Box<Expression>,
when_false: Box<Expression>,
},
Call {
definition: Reference,
arguments: Vec<Argument>,
},
Sum {
values: Box<Expression>,
initial: Box<Expression>,
},
Power {
base: Box<Expression>,
exponent: i8,
},
AsKind {
kind: Reference,
value: Box<Expression>,
},
}
#[derive(Debug, PartialEq, Eq)]
pub enum Binding {
Unbound(Span),
Expression(Expression),
}
pub fn parse(source: &str, budget: &mut Budget) -> Result<Binding, Error> {
let tokens = lex::tokens(source, budget)?;
let mut cursor = cursor::Cursor::new(source, &tokens, budget);
let binding = syntax::binding(&mut cursor)?;
cursor.end()?;
Ok(binding)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,162 @@
//! Candidate limits for measured adoption; one owner spans all parsed files.
use super::{Error, Span, error};
use crate::analysis_numeric::{Exact, WorkLimits, WorkMeter};
use crate::language::ParseLimits;
#[derive(Clone, Copy, Debug)]
pub struct Limits {
pub bytes: usize,
pub tokens: usize,
pub entries: usize,
pub references: usize,
pub frames: usize,
pub numeric_units: u64,
}
impl Limits {
pub fn source8(caller: ParseLimits) -> Self {
let bytes = caller.max_source_bytes.min(64 * 1024 * 1024);
Self {
bytes,
tokens: bytes.min(1_048_576),
entries: caller.max_records.min(262_144),
references: caller.max_step_references.min(500_000),
frames: 256,
numeric_units: 50_000_000,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Usage {
pub bytes: usize,
pub tokens: usize,
pub entries: usize,
pub references: usize,
pub deepest_frame: usize,
}
pub struct Budget {
limits: Limits,
pub usage: Usage,
numeric: WorkMeter,
failed: Option<Error>,
}
impl Budget {
pub fn new(limits: Limits) -> Result<Self, Error> {
let hard = Limits::source8(ParseLimits::UNLIMITED);
if limits.bytes > hard.bytes
|| limits.tokens > hard.tokens
|| limits.entries > hard.entries
|| limits.references > hard.references
|| limits.frames > hard.frames
|| limits.numeric_units > hard.numeric_units
{
return Err(error(
Span::default(),
"Source8 profile cannot exceed candidate hard limits.",
));
}
Ok(Self {
limits,
usage: Usage::default(),
numeric: WorkMeter::new(
WorkLimits::new(0, limits.numeric_units)
.map_err(|e| error(Span::default(), e.to_string()))?,
),
failed: None,
})
}
pub fn active(&self) -> Result<(), Error> {
self.failed.clone().map_or(Ok(()), Err)
}
fn charge(
&mut self,
used: usize,
amount: usize,
maximum: usize,
span: Span,
name: &str,
) -> Result<(), Error> {
self.active()?;
if amount > maximum.saturating_sub(used) {
let failure = error(span, format!("Source8 {name} limit exceeded."));
self.failed = Some(failure.clone());
return Err(failure);
}
Ok(())
}
pub fn bytes(&mut self, amount: usize, span: Span) -> Result<(), Error> {
self.charge(self.usage.bytes, amount, self.limits.bytes, span, "bytes")?;
self.usage.bytes += amount;
Ok(())
}
pub fn token(&mut self, span: Span) -> Result<(), Error> {
self.charge(self.usage.tokens, 1, self.limits.tokens, span, "tokens")?;
self.usage.tokens += 1;
Ok(())
}
pub fn entry(&mut self, span: Span) -> Result<(), Error> {
self.charge(
self.usage.entries,
1,
self.limits.entries,
span,
"syntax entries",
)?;
self.usage.entries += 1;
Ok(())
}
pub fn reference(&mut self, span: Span) -> Result<(), Error> {
self.charge(
self.usage.references,
1,
self.limits.references,
span,
"references",
)?;
self.usage.references += 1;
Ok(())
}
pub fn frame(&mut self, depth: usize, span: Span) -> Result<(), Error> {
self.charge(0, depth, self.limits.frames, span, "syntax frames")?;
self.usage.deepest_frame = self.usage.deepest_frame.max(depth);
Ok(())
}
pub fn exact(&mut self, spelling: &str, span: Span) -> Result<Exact, Error> {
self.active()?;
match Exact::parse_literal(spelling, &mut self.numeric) {
Ok(value) => Ok(value),
Err(cause) => {
let failure = error(span, cause.to_string());
self.failed = Some(failure.clone());
Err(failure)
}
}
}
pub fn numeric_units(&self) -> u64 {
self.numeric.usage().numeric_units
}
/// Check phase allocation bounds without recharging parsed syntax or exact
/// work. S4 must also charge ordinary records through the inherited budget.
pub fn lowering_entries(&self, count: usize, span: Span) -> Result<(), Error> {
self.active()?;
if count > self.limits.entries {
return Err(error(span, "Source8 lowering entry limit exceeded."));
}
Ok(())
}
/// Aggregate owned text for an index/update phase, checked before copying.
pub fn index_bytes(&self, count: usize, span: Span) -> Result<(), Error> {
self.active()?;
if count > self.limits.bytes {
return Err(error(span, "Source8 index/output byte limit exceeded."));
}
Ok(())
}
pub fn lowering_occurrences(&self, count: usize, span: Span) -> Result<(), Error> {
self.active()?;
// Parameter declarations are syntax entries, not reference uses.
if count > self.limits.references.saturating_add(self.limits.entries) {
return Err(error(span, "Source8 lowering occurrence limit exceeded."));
}
Ok(())
}
}
@@ -0,0 +1,171 @@
//! A borrowed significant-token cursor. Spans always refer to the full source.
use super::*;
use lex::{Class, Token};
pub(super) struct Cursor<'a, 'b> {
pub source: &'a str,
pub tokens: &'a [Token],
pub cursor: usize,
pub frames: usize,
pub budget: &'b mut Budget,
}
impl<'a, 'b> Cursor<'a, 'b> {
pub(super) fn new(source: &'a str, tokens: &'a [Token], budget: &'b mut Budget) -> Self {
Self {
source,
tokens,
cursor: 0,
frames: 0,
budget,
}
}
}
impl Cursor<'_, '_> {
pub(super) fn span(&self) -> Span {
self.tokens.get(self.cursor).map_or(
Span {
start: self.source.len(),
end: self.source.len(),
},
|token| token.span,
)
}
pub(super) fn text(&self) -> &str {
let span = self.span();
&self.source[span.start..span.end]
}
pub(super) fn take(&mut self, expected: &str) -> Result<Span, Error> {
if self.text() != expected {
return Err(error(self.span(), format!("Expected '{expected}'.")));
}
let span = self.span();
self.cursor += 1;
Ok(span)
}
pub(super) fn end(&self) -> Result<(), Error> {
if self.cursor != self.tokens.len() {
Err(error(
self.span(),
"Unexpected content after analysis expression.",
))
} else {
Ok(())
}
}
pub(super) fn open(&mut self) -> Result<(), Error> {
self.budget.frame(self.frames + 1, self.span())?;
self.frames += 1;
Ok(())
}
pub(super) fn close(&mut self) {
self.frames -= 1;
}
pub(super) fn reference(&mut self) -> Result<Reference, Error> {
self.budget.reference(self.span())?;
self.name()
}
pub(super) fn name(&mut self) -> Result<Reference, Error> {
let span = self.span();
if !self
.tokens
.get(self.cursor)
.is_some_and(|token| token.class == Class::Word)
|| !crate::valid_alias(self.text())
{
return Err(error(span, "Expected a valid reference or parameter name."));
}
let name = self.text().to_owned();
self.cursor += 1;
Ok(Reference { name, span })
}
pub(super) fn quoted(&mut self) -> Result<String, Error> {
let span = self.span();
if !self
.tokens
.get(self.cursor)
.is_some_and(|token| token.class == Class::String)
{
return Err(error(span, "Expected a quoted string."));
}
let value =
serde_json::from_str(self.text()).map_err(|_| error(span, "Invalid quoted string."))?;
self.cursor += 1;
Ok(value)
}
pub(super) fn last_end(&self) -> usize {
self.tokens[self.cursor - 1].span.end
}
pub(super) fn exact_literal(&mut self) -> Result<(Exact, LiteralSyntax), Error> {
if self.text() == "ratio" {
return self.ratio();
}
let span = self.span();
if !self
.tokens
.get(self.cursor)
.is_some_and(|token| token.class == Class::Number)
{
return Err(error(span, "Expected an exact numeric atom or ratio."));
}
let value = self
.budget
.exact(&self.source[span.start..span.end], span)?;
self.cursor += 1;
Ok((
value,
LiteralSyntax {
span,
tokens: vec![span],
},
))
}
pub(super) fn integer(&mut self) -> Result<Span, Error> {
let span = self.span();
let text = self.text();
if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
return Err(error(span, "Expected unsigned integer digits."));
}
if text.len() > crate::analysis_numeric::MAX_COMPONENT_DIGITS {
return Err(error(span, "Integer component exceeds digit limit."));
}
self.cursor += 1;
Ok(span)
}
pub(super) fn ratio(&mut self) -> Result<(Exact, LiteralSyntax), Error> {
let first_token = self.cursor;
let start = self.take("ratio")?.start;
self.open()?;
self.take("(")?;
let negative = if self.text() == "-" {
self.cursor += 1;
true
} else {
false
};
let numerator = self.integer()?;
self.take(",")?;
let denominator = self.integer()?;
let end = self.take(")")?.end;
self.close();
let n = &self.source[numerator.start..numerator.end];
let d = &self.source[denominator.start..denominator.end];
let length = 9 + usize::from(negative) + n.len() + d.len();
if length > crate::analysis_numeric::MAX_LITERAL_BYTES {
return Err(error(
Span { start, end },
"Numeric reconstruction exceeds literal byte limit.",
));
}
let normalized = format!("ratio({}{n}, {d})", if negative { "-" } else { "" });
Ok((
self.budget.exact(&normalized, Span { start, end })?,
LiteralSyntax {
span: Span { start, end },
tokens: self.tokens[first_token..self.cursor]
.iter()
.map(|token| token.span)
.collect(),
},
))
}
}
@@ -0,0 +1,635 @@
//! Required typed declaration syntax. No model construction or resolution.
use super::cursor::Cursor;
use super::*;
use crate::{Id, Layer};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeclarationKind {
QuantityKind,
Unit,
Calculation,
Property(Layer),
Check(Layer),
Case(Layer),
}
/// Contextual recognition only: never add these words to the global alias ban.
pub fn declaration_kind(word: &str) -> Option<DeclarationKind> {
use DeclarationKind::*;
Some(match word {
"quantity_kind" => QuantityKind,
"unit" => Unit,
"calculation" => Calculation,
"oa_property" => Property(Layer::Oa),
"sa_property" => Property(Layer::Sa),
"oa_check" => Check(Layer::Oa),
"sa_check" => Check(Layer::Sa),
"oa_analysis" => Case(Layer::Oa),
"sa_analysis" => Case(Layer::Sa),
_ => return None,
})
}
#[derive(Debug, PartialEq, Eq)]
pub struct IdentitySyntax {
/// Missing IDs stay unresolved here. The bundle stabilization owner assigns
/// and inserts an ID once, then reparses the actual returned bytes.
pub value: Option<Id>,
pub keyword: Option<Span>,
pub literal: Option<Span>,
pub insertion: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Header {
pub kind: DeclarationKind,
pub kind_span: Span,
pub alias: Reference,
pub name: String,
pub name_span: Span,
pub identity: IdentitySyntax,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Field<T> {
/// Complete field including its keyword and trailing semicolon.
pub span: Span,
pub keyword: Span,
/// Same meaning as an ordinary top-level field value span.
pub value_span: Span,
pub value: T,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ScalarSyntax {
pub span: Span,
pub kind: ScalarKind,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ScalarKind {
Boolean,
Number,
Quantity(Reference),
}
#[derive(Debug, PartialEq, Eq)]
pub struct TypeSyntax {
pub span: Span,
pub scalar: ScalarSyntax,
pub list: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ParameterSyntax {
pub span: Span,
pub name: Reference,
pub identity: IdentitySyntax,
pub value_type: TypeSyntax,
}
#[derive(Debug, PartialEq, Eq)]
pub struct DimensionEntry {
pub axis: Span,
pub exponent: Span,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub struct DimensionSyntax {
pub exponents: [i16; 7],
pub entries: Vec<DimensionEntry>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum AlgebraSyntax {
Linear,
Affine(Reference),
}
#[derive(Debug, PartialEq, Eq)]
pub struct ExactSyntax {
pub value: Exact,
pub literal: LiteralSyntax,
}
#[derive(Debug, PartialEq, Eq)]
pub struct OverrideSyntax {
pub span: Span,
pub target: Reference,
pub binding: Binding,
pub binding_span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Body {
QuantityKind {
dimension: Field<DimensionSyntax>,
algebra: Field<AlgebraSyntax>,
},
Unit {
kind: Field<Reference>,
scale: Field<ExactSyntax>,
offset: Field<ExactSyntax>,
symbol: Field<String>,
},
Calculation {
parameters: Vec<ParameterSyntax>,
result: Field<TypeSyntax>,
body: Field<Binding>,
},
Property {
subject: Field<Reference>,
value_type: Field<TypeSyntax>,
base: Field<Binding>,
display_unit: Option<Field<Option<Reference>>>,
},
Check {
subject: Option<Field<Option<Reference>>>,
documented_constraint: Option<Field<Option<Reference>>>,
predicate: Field<Binding>,
},
Case {
subject: Option<Field<Option<Reference>>>,
base: Field<()>,
overrides: Field<Vec<OverrideSyntax>>,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct Declaration {
pub header: Header,
pub span: Span,
pub body_span: Span,
pub description: Option<Field<String>>,
/// Analysis roots may explicitly spell `parent none`; definitions may not.
pub parent: Option<Field<()>>,
pub body: Body,
}
#[derive(Default)]
struct Fields {
dimension: Option<Field<DimensionSyntax>>,
algebra: Option<Field<AlgebraSyntax>>,
kind: Option<Field<Reference>>,
scale: Option<Field<ExactSyntax>>,
offset: Option<Field<ExactSyntax>>,
symbol: Option<Field<String>>,
result: Option<Field<TypeSyntax>>,
body: Option<Field<Binding>>,
subject: Option<Field<Reference>>,
optional_subject: Option<Field<Option<Reference>>>,
value_type: Option<Field<TypeSyntax>>,
base: Option<Field<Binding>>,
display_unit: Option<Field<Option<Reference>>>,
documented_constraint: Option<Field<Option<Reference>>>,
predicate: Option<Field<Binding>>,
baseline: Option<Field<()>>,
overrides: Option<Field<Vec<OverrideSyntax>>>,
}
fn required<T>(value: Option<T>, name: &str, at: Span) -> Result<T, Error> {
value.ok_or_else(|| error(at, format!("Missing required field '{name}'.")))
}
impl Fields {
fn finish(
self,
kind: DeclarationKind,
parameters: Vec<ParameterSyntax>,
at: Span,
) -> Result<Body, Error> {
Ok(match kind {
DeclarationKind::QuantityKind => Body::QuantityKind {
dimension: required(self.dimension, "dimension", at)?,
algebra: required(self.algebra, "algebra", at)?,
},
DeclarationKind::Unit => Body::Unit {
kind: required(self.kind, "kind", at)?,
scale: required(self.scale, "scale", at)?,
offset: required(self.offset, "offset", at)?,
symbol: required(self.symbol, "symbol", at)?,
},
DeclarationKind::Calculation => Body::Calculation {
parameters,
result: required(self.result, "result", at)?,
body: required(self.body, "body", at)?,
},
DeclarationKind::Property(_) => Body::Property {
subject: required(self.subject, "subject", at)?,
value_type: required(self.value_type, "type", at)?,
base: required(self.base, "base", at)?,
display_unit: self.display_unit,
},
DeclarationKind::Check(_) => Body::Check {
subject: self.optional_subject,
documented_constraint: self.documented_constraint,
predicate: required(self.predicate, "predicate", at)?,
},
DeclarationKind::Case(_) => Body::Case {
subject: self.optional_subject,
base: required(self.baseline, "base", at)?,
overrides: required(self.overrides, "overrides", at)?,
},
})
}
}
/// Consume exactly one declaration from already charged full-file tokens.
/// `version` is inherited from the caller's entry policy; fragments have no
/// independent version header. Public parsing does not call this staged API.
pub(super) fn parse(
p: &mut Cursor<'_, '_>,
version: u32,
lexical_owner: Option<Id>,
) -> Result<Declaration, Error> {
p.budget.active()?;
let kind_span = p.span();
let kind = declaration_kind(p.text())
.ok_or_else(|| error(kind_span, "Expected a short typed declaration word."))?;
if version != 8 {
return Err(error(kind_span, "Analysis declarations require source8."));
}
if lexical_owner.is_some() {
return Err(error(
kind_span,
"Analysis declarations and definitions must be roots.",
));
}
p.budget.entry(kind_span)?;
p.cursor += 1;
let alias = p.name()?;
let name_span = p.span();
let name = p.quoted()?;
let identity = p.identity()?;
p.open()?;
let opening = p.take("{")?;
let header = Header {
kind,
kind_span,
alias,
name,
name_span,
identity,
span: Span {
start: kind_span.start,
end: opening.start,
},
};
let mut fields = Fields::default();
let mut description = None;
let mut parent = None;
let mut parameters = Vec::new();
let mut seen = BTreeSet::new();
let mut parameter_names = BTreeSet::new();
let mut parameter_ids = BTreeSet::new();
while p.text() != "}" {
let keyword = p.span();
p.budget.entry(keyword)?;
if p.text() == "input" && kind == DeclarationKind::Calculation {
let parameter = p.parameter()?;
if !parameter_names.insert(parameter.name.name.clone()) {
return Err(error(parameter.name.span, "Duplicate parameter name."));
}
if let Some(id) = parameter.identity.value
&& !parameter_ids.insert(id)
{
return Err(error(
parameter.identity.literal.expect("explicit identity"),
"Duplicate parameter identity.",
));
}
parameters.push(parameter);
continue;
}
// Borrow the original spelling for duplicate detection; no field-name
// allocation and no map insertion can overwrite an earlier value.
let source = p.source;
let key = &source[keyword.start..keyword.end];
if !seen.insert(key) {
return Err(error(keyword, format!("Duplicate field '{key}'.")));
}
p.cursor += 1;
use DeclarationKind::*;
match (kind, key) {
(_, "description") => description = Some(p.field(keyword, |p| p.quoted())?),
(Property(_) | Check(_) | Case(_), "parent") => {
parent = Some(p.field(keyword, |p| {
p.take("none")?;
Ok(())
})?)
}
(QuantityKind, "dimension") => {
fields.dimension = Some(p.field(keyword, |p| p.dimension())?)
}
(QuantityKind, "algebra") => fields.algebra = Some(p.field(keyword, |p| p.algebra())?),
(Unit, "kind") => fields.kind = Some(p.field(keyword, |p| p.at_reference())?),
(Unit, "scale") => fields.scale = Some(p.field(keyword, |p| p.exact_value())?),
(Unit, "offset") => fields.offset = Some(p.field(keyword, |p| p.exact_value())?),
(Unit, "symbol") => fields.symbol = Some(p.field(keyword, |p| p.quoted())?),
(Calculation, "result") => fields.result = Some(p.field(keyword, |p| p.value_type())?),
(Calculation, "body") => fields.body = Some(p.field(keyword, syntax::binding)?),
(Property(_), "subject") => {
fields.subject = Some(p.field(keyword, |p| p.at_reference())?)
}
(Property(_), "type") => {
fields.value_type = Some(p.field(keyword, |p| p.value_type())?)
}
(Property(_), "base") => fields.base = Some(p.field(keyword, syntax::binding)?),
(Property(_), "display_unit") => {
fields.display_unit = Some(p.field(keyword, |p| p.optional_reference())?)
}
(Check(_) | Case(_), "subject") => {
fields.optional_subject = Some(p.field(keyword, |p| p.optional_reference())?)
}
(Check(_), "documented_constraint") => {
fields.documented_constraint = Some(p.field(keyword, |p| p.optional_reference())?)
}
(Check(_), "predicate") => fields.predicate = Some(p.field(keyword, syntax::binding)?),
(Case(_), "base") => {
fields.baseline = Some(p.field(keyword, |p| {
p.take("ModelBaseline")?;
Ok(())
})?)
}
(Case(_), "overrides") => fields.overrides = Some(p.field(keyword, |p| p.overrides())?),
_ => {
return Err(error(
keyword,
format!("Unknown field '{key}' for this typed declaration."),
));
}
}
}
let closing = p.take("}")?;
p.close();
let body = fields.finish(kind, parameters, closing)?;
Ok(Declaration {
header,
span: Span {
start: kind_span.start,
end: closing.end,
},
body_span: Span {
start: opening.end,
end: closing.start,
},
description,
parent,
body,
})
}
impl Cursor<'_, '_> {
fn field<T>(
&mut self,
keyword: Span,
parse: impl FnOnce(&mut Self) -> Result<T, Error>,
) -> Result<Field<T>, Error> {
let start = self.span().start;
let value = parse(self)?;
let end = self.last_end();
let terminator = self.take(";")?;
Ok(Field {
span: Span {
start: keyword.start,
end: terminator.end,
},
keyword,
value_span: Span { start, end },
value,
})
}
fn identity(&mut self) -> Result<IdentitySyntax, Error> {
if self.text() != "id" {
return Ok(IdentitySyntax {
value: None,
keyword: None,
literal: None,
insertion: Some(self.span().start),
});
}
let keyword = self.take("id")?;
let literal = self.span();
let spelling = self.quoted()?;
if spelling.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|at| spelling.as_bytes()[at] == b'-')
{
return Err(error(
literal,
"Expected UUID in 8-4-4-4-12 hexadecimal form.",
));
}
let id = Id::parse_str(&spelling).map_err(|_| error(literal, "Invalid UUID identity."))?;
Ok(IdentitySyntax {
value: Some(id),
keyword: Some(keyword),
literal: Some(literal),
insertion: None,
})
}
fn parameter(&mut self) -> Result<ParameterSyntax, Error> {
let start = self.take("input")?.start;
let name = self.name()?;
let identity = self.identity()?;
self.take(":")?;
let value_type = self.value_type()?;
let end = self.take(";")?.end;
Ok(ParameterSyntax {
span: Span { start, end },
name,
identity,
value_type,
})
}
fn at_reference(&mut self) -> Result<Reference, Error> {
self.take("@")?;
self.reference()
}
fn optional_reference(&mut self) -> Result<Option<Reference>, Error> {
if self.text() == "none" {
self.cursor += 1;
Ok(None)
} else {
self.at_reference().map(Some)
}
}
fn exact_value(&mut self) -> Result<ExactSyntax, Error> {
let (value, literal) = self.exact_literal()?;
Ok(ExactSyntax { value, literal })
}
fn value_type(&mut self) -> Result<TypeSyntax, Error> {
let start = self.span().start;
self.budget.entry(self.span())?;
let list = self.text() == "List";
if list {
self.open()?;
self.cursor += 1;
self.take("(")?;
}
let scalar = self.scalar_type()?;
if list {
self.take(")")?;
self.close();
}
Ok(TypeSyntax {
span: Span {
start,
end: self.last_end(),
},
scalar,
list,
})
}
fn scalar_type(&mut self) -> Result<ScalarSyntax, Error> {
let start = self.span().start;
let kind = match self.text() {
"Boolean" => {
self.cursor += 1;
ScalarKind::Boolean
}
"Number" => {
self.cursor += 1;
ScalarKind::Number
}
"Quantity" => {
self.open()?;
self.cursor += 1;
self.take("(")?;
let reference = self.at_reference()?;
self.take(")")?;
self.close();
ScalarKind::Quantity(reference)
}
_ => {
return Err(error(
self.span(),
"Expected Boolean, Number or Quantity(@kind); nested List is not a scalar type.",
));
}
};
Ok(ScalarSyntax {
span: Span {
start,
end: self.last_end(),
},
kind,
})
}
fn dimension(&mut self) -> Result<DimensionSyntax, Error> {
self.open()?;
self.take("{")?;
let mut exponents = [0; 7];
let mut seen = [false; 7];
let mut entries = Vec::new();
while self.text() != "}" {
let axis = self.span();
self.budget.entry(axis)?;
let index = match self.text() {
"length" => 0,
"mass" => 1,
"time" => 2,
"electric_current" => 3,
"temperature" => 4,
"amount" => 5,
"luminous_intensity" => 6,
_ => return Err(error(axis, "Unknown dimension axis.")),
};
if seen[index] {
return Err(error(axis, "Duplicate dimension axis."));
}
seen[index] = true;
self.cursor += 1;
let start = self.span().start;
let negative = self.text() == "-";
if negative {
self.cursor += 1;
}
let digits = self.integer()?;
let value = self.source[digits.start..digits.end]
.parse::<i16>()
.map_err(|_| error(digits, "Dimension exponent exceeds -32..32."))?;
if value > 32 {
return Err(error(digits, "Dimension exponent exceeds -32..32."));
}
exponents[index] = if negative { -value } else { value };
let exponent = Span {
start,
end: digits.end,
};
let end = self.take(";")?.end;
entries.push(DimensionEntry {
axis,
exponent,
span: Span {
start: axis.start,
end,
},
});
}
self.take("}")?;
self.close();
Ok(DimensionSyntax { exponents, entries })
}
fn algebra(&mut self) -> Result<AlgebraSyntax, Error> {
if self.text() == "Linear" {
self.cursor += 1;
return Ok(AlgebraSyntax::Linear);
}
self.take("Affine")?;
self.open()?;
self.take("(")?;
let difference = self.at_reference()?;
self.take(")")?;
self.close();
Ok(AlgebraSyntax::Affine(difference))
}
fn overrides(&mut self) -> Result<Vec<OverrideSyntax>, Error> {
self.open()?;
self.take("{")?;
let mut entries = Vec::new();
let mut names = BTreeSet::new();
while self.text() != "}" {
let start = self.span().start;
self.budget.entry(self.span())?;
let target = self.at_reference()?;
if !names.insert(target.name.clone()) {
return Err(error(target.span, "Duplicate override target."));
}
self.take("=")?;
let binding_start = self.span().start;
let binding = syntax::binding(self)?;
let binding_span = Span {
start: binding_start,
end: self.last_end(),
};
let end = self.take(";")?.end;
entries.push(OverrideSyntax {
span: Span { start, end },
target,
binding,
binding_span,
});
}
self.take("}")?;
self.close();
Ok(entries)
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,613 @@
use super::*;
use crate::language::ParseLimits;
use crate::language::analysis_source::budget::Limits;
fn limits() -> Limits {
Limits::source8(ParseLimits::UNLIMITED)
}
fn budget() -> Budget {
Budget::new(limits()).unwrap()
}
fn declaration(source: &str) -> Declaration {
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
let mut cursor = Cursor::new(source, &tokens, &mut budget);
let result = parse(&mut cursor, 8, None).unwrap();
cursor.end().unwrap();
result
}
fn failure(source: &str) -> Error {
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
parse(&mut Cursor::new(source, &tokens, &mut budget), 8, None).unwrap_err()
}
fn text(source: &str, span: Span) -> &str {
&source[span.start..span.end]
}
#[test]
fn required_declarations_have_independent_typed_body_and_header_oracles() {
let source = "quantity_kind duration \"Duration\" id \"188b911c-5f8b-431c-9eea-2d41f3953767\" { dimension { time 1; length -2; }; algebra Affine(@difference); description \"雪\"; }";
let actual = declaration(source);
assert_eq!(actual.header.kind, DeclarationKind::QuantityKind);
assert_eq!(actual.header.alias.name, "duration");
assert_eq!(actual.header.name, "Duration");
assert_eq!(
actual.header.identity.value,
Some(Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap())
);
assert_eq!(actual.description.as_ref().unwrap().value, "雪");
assert!(actual.parent.is_none());
let Body::QuantityKind { dimension, algebra } = actual.body else {
panic!("quantity kind")
};
assert_eq!(dimension.value.exponents, [-2, 0, 1, 0, 0, 0, 0]);
assert_eq!(text(source, dimension.keyword), "dimension");
assert_eq!(text(source, dimension.value_span), "{ time 1; length -2; }");
assert_eq!(text(source, dimension.value.entries[1].exponent), "-2");
let AlgebraSyntax::Affine(difference) = algebra.value else {
panic!("affine")
};
assert_eq!(difference.name, "difference");
let Body::Unit { kind, scale, offset, symbol } = declaration("unit millisecond \"Millisecond\" { kind @duration; scale ratio (1, // divisor\n 1000); offset 0; symbol \"ms\"; }").body else { panic!("unit") };
assert_eq!(kind.value.name, "duration");
assert_eq!(scale.value.value.canonical_literal(), "ratio(1, 1000)");
assert_eq!(offset.value.value.canonical_literal(), "0");
assert_eq!(symbol.value, "ms");
let Body::Calculation { parameters, result, body } = declaration("calculation combine \"Combine durations\" { input a id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Quantity(@duration); input b id \"7a47cc1e-8ae5-4551-b2d8-8a68108e0976\" : Quantity(@duration); result Quantity(@duration); body $a + $b; }").body else { panic!("calculation") };
assert_eq!(
parameters
.iter()
.map(|p| p.name.name.as_str())
.collect::<Vec<_>>(),
["a", "b"]
);
assert!(!result.value.list);
assert!(
matches!(result.value.scalar.kind, ScalarKind::Quantity(ref r) if r.name == "duration")
);
let Binding::Expression(Expression {
kind: ExpressionKind::Binary { op, left, right },
..
}) = body.value
else {
panic!("binary")
};
assert_eq!(op, BinaryOp::Add);
assert!(matches!(left.kind, ExpressionKind::Parameter(ref r) if r.name == "a"));
assert!(matches!(right.kind, ExpressionKind::Parameter(ref r) if r.name == "b"));
for (word, layer) in [("oa_property", Layer::Oa), ("sa_property", Layer::Sa)] {
let actual = declaration(&format!(
"{word} receive_time \"Receive time\" {{ subject @receive; type Quantity(@duration); base 750 [@millisecond]; display_unit @millisecond; parent none; }}"
));
assert_eq!(actual.header.kind, DeclarationKind::Property(layer));
assert!(actual.parent.is_some());
let Body::Property {
subject,
value_type,
base,
display_unit,
} = actual.body
else {
panic!("property")
};
assert_eq!(subject.value.name, "receive");
assert!(matches!(
value_type.value.scalar.kind,
ScalarKind::Quantity(_)
));
assert!(matches!(
base.value,
Binding::Expression(Expression {
kind: ExpressionKind::Quantity { .. },
..
})
));
assert_eq!(display_unit.unwrap().value.unwrap().name, "millisecond");
}
for word in ["oa_check", "sa_check"] {
let Body::Check { subject, documented_constraint, predicate } = declaration(&format!("{word} budget \"Response budget\" {{ subject none; documented_constraint @constraint; predicate @total_time <= @limit; }}")).body else { panic!("check") };
assert!(subject.unwrap().value.is_none());
assert_eq!(
documented_constraint.unwrap().value.unwrap().name,
"constraint"
);
assert!(matches!(
predicate.value,
Binding::Expression(Expression {
kind: ExpressionKind::Binary {
op: BinaryOp::LessOrEqual,
..
},
..
})
));
}
for word in ["oa_analysis", "sa_analysis"] {
let Body::Case { subject, base, overrides } = declaration(&format!("{word} degraded \"Degraded radio\" {{ subject @respond; base ModelBaseline; overrides {{ @receive_time = unbound; }}; }}")).body else { panic!("case") };
assert_eq!(subject.unwrap().value.unwrap().name, "respond");
assert!(base.value_span.end > base.value_span.start);
assert_eq!(overrides.value.len(), 1);
assert_eq!(overrides.value[0].target.name, "receive_time");
assert!(matches!(overrides.value[0].binding, Binding::Unbound(_)));
}
}
#[test]
fn empty_unbound_and_all_scalar_list_types_are_explicit_valid_syntax() {
for (spelling, list, kind) in [
("Boolean", false, 0),
("Number", false, 1),
("Quantity(@duration)", false, 2),
("List(Boolean)", true, 0),
("List(Number)", true, 1),
("List(Quantity(@duration))", true, 2),
] {
let Body::Calculation {
parameters,
result,
body,
} = declaration(&format!(
"calculation c \"C\" {{ result {spelling}; body unbound; }}"
))
.body
else {
panic!()
};
assert!(parameters.is_empty());
assert_eq!(result.value.list, list);
assert_eq!(
match result.value.scalar.kind {
ScalarKind::Boolean => 0,
ScalarKind::Number => 1,
ScalarKind::Quantity(_) => 2,
},
kind
);
assert!(matches!(body.value, Binding::Unbound(_)));
}
let Body::QuantityKind { dimension, algebra } =
declaration("quantity_kind q \"Q\" { dimension {}; algebra Linear; }").body
else {
panic!()
};
assert_eq!(dimension.value.exponents, [0; 7]);
assert!(dimension.value.entries.is_empty());
assert_eq!(algebra.value, AlgebraSyntax::Linear);
let Body::Case {
subject, overrides, ..
} = declaration("oa_analysis c \"C\" { base ModelBaseline; overrides {}; }").body
else {
panic!()
};
assert!(subject.is_none());
assert!(overrides.value.is_empty());
let Body::Check {
subject,
documented_constraint,
..
} = declaration("oa_check c \"C\" { predicate unbound; }").body
else {
panic!()
};
assert!(subject.is_none() && documented_constraint.is_none());
let Body::Property { display_unit, .. } =
declaration("oa_property p \"P\" { subject @s; type Number; base unbound; }").body
else {
panic!()
};
assert!(display_unit.is_none());
}
#[test]
fn missing_required_fields_and_unknown_or_duplicate_entries_reject_locally() {
let required_sets: &[(&str, &[&str])] = &[
(
"quantity_kind q \"Q\"",
&["dimension {};", "algebra Linear;"],
),
(
"unit u \"U\"",
&["kind @q;", "scale 1;", "offset 0;", "symbol \"u\";"],
),
("calculation c \"C\"", &["result Number;", "body unbound;"]),
(
"oa_property p \"P\"",
&["subject @s;", "type Number;", "base unbound;"],
),
("oa_check c \"C\"", &["predicate unbound;"]),
(
"oa_analysis a \"A\"",
&["base ModelBaseline;", "overrides {};"],
),
];
for (header, fields) in required_sets {
declaration(&format!("{header} {{ {} }}", fields.join(" ")));
for removed in 0..fields.len() {
let remaining = fields
.iter()
.enumerate()
.filter_map(|(i, field)| (i != removed).then_some(*field))
.collect::<Vec<_>>()
.join(" ");
let error = failure(&format!("{header} {{ {remaining} }}"));
assert!(
error.message.contains("Missing required field"),
"{error:?}"
);
let duplicate = format!("{header} {{ {} {} }}", fields.join(" "), fields[removed]);
assert!(failure(&duplicate).message.contains("Duplicate field"));
}
}
for source in [
"quantity_kind q \"Q\" { dimension { time 1; time 2; }; algebra Linear; }",
"quantity_kind q \"Q\" { dimension { unknown 1; }; algebra Linear; }",
"quantity_kind q \"Q\" { dimension { time 33; }; algebra Linear; }",
"unit u \"U\" { kind @q; scale 1 + 2; offset 0; symbol \"u\"; }",
"unit u \"U\" { kind @q; scale 1 [@u]; offset 0; symbol \"u\"; }",
"unit u \"U\" { kind @q; scale ratio(1, 0); offset 0; symbol \"u\"; }",
"calculation c \"C\" { result List(List(Number)); body unbound; }",
"calculation c \"C\" { input x : Number; input x : Number; result Number; body unbound; }",
"calculation c \"C\" { input x id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Number; input y id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Number; result Number; body unbound; }",
"oa_property p \"P\" { subject none; type Number; base unbound; }",
"oa_property p \"P\" { subject @s; type Number; base none; }",
"oa_property p \"P\" { subject @s; type Number; base unbound; parent @s; }",
"oa_analysis a \"A\" { base @another; overrides {}; }",
"oa_analysis a \"A\" { base ModelBaseline; overrides { @p = clear; }; }",
"oa_analysis a \"A\" { base ModelBaseline; overrides { @p = 1; @p = 2; }; }",
"oa_check c \"C\" { predicate @f(x: 1, x: 2); }",
"quantity_kind q \"Q\" { dimension {}; algebra Linear; parent none; }",
"oa_check c \"C\" { predicate unbound; invented none; }",
] {
failure(source);
}
let duplicate = "oa_analysis a \"A\" { base ModelBaseline; overrides { @p = 1; @p = 2; }; }";
let error = failure(duplicate);
assert_eq!(text(duplicate, error.span), "p");
assert!(error.span.start > duplicate.find("@p").unwrap() + 1);
}
#[test]
fn borrowed_cursor_preserves_full_file_spans_and_does_not_rescan_fields() {
let source = "// 雪\r\ncalculation c \"C\" { input a : Number; result Number; body @combine(a: ratio (1, // first\r\n 2), b: $a) + @combine(a: 2, b: $a); }\r\nunit u \"U\" { kind @q; scale 1; offset 0; symbol \"u\"; }";
let mut shared = budget();
let tokens = lex::tokens(source, &mut shared).unwrap();
let lexical = shared.usage;
let mut cursor = Cursor::new(source, &tokens, &mut shared);
let first = parse(&mut cursor, 8, None).unwrap();
assert_eq!(cursor.text(), "unit");
let first_end = first.span.end;
let second = parse(&mut cursor, 8, None).unwrap();
cursor.end().unwrap();
assert!(second.span.start > first_end);
assert_eq!(text(source, first.header.kind_span), "calculation");
assert_eq!(text(source, first.header.name_span), "\"C\"");
assert_eq!(text(source, second.header.alias.span), "u");
assert_eq!(
text(source, first.body_span).trim().strip_prefix("input"),
Some(
" a : Number; result Number; body @combine(a: ratio (1, // first\r\n 2), b: $a) + @combine(a: 2, b: $a);"
)
);
let Body::Calculation {
parameters, body, ..
} = first.body
else {
panic!()
};
assert_eq!(text(source, parameters[0].span), "input a : Number;");
assert_eq!(text(source, parameters[0].value_type.span), "Number");
let Binding::Expression(Expression {
kind: ExpressionKind::Binary { left, right, .. },
..
}) = body.value
else {
panic!()
};
let ExpressionKind::Call {
arguments: left, ..
} = left.kind
else {
panic!()
};
let ExpressionKind::Call {
arguments: right, ..
} = right.kind
else {
panic!()
};
let spans = [
left[0].parameter.span,
left[1].parameter.span,
right[0].parameter.span,
right[1].parameter.span,
];
assert_eq!(spans.map(|span| text(source, span)), ["a", "b", "a", "b"]);
assert!(spans.windows(2).all(|pair| pair[0].end < pair[1].start));
let literal = left[0].value.literal.as_ref().unwrap();
assert_eq!(text(source, literal.span), "ratio (1, // first\r\n 2)");
assert_eq!(
literal
.tokens
.iter()
.map(|span| text(source, *span))
.collect::<Vec<_>>(),
["ratio", "(", "1", ",", "2", ")"]
);
assert_eq!(shared.usage.bytes, source.len());
assert_eq!(shared.usage.bytes, lexical.bytes);
assert_eq!(shared.usage.tokens, lexical.tokens);
assert_eq!(shared.usage.references, 9); // two calls, four keys, two $a, unit kind.
}
#[test]
fn missing_id_metadata_is_stable_and_parameter_ids_are_owner_scoped() {
let source =
"calculation c \"C\" { input a // identity here\r\n : Number; result Number; body $a; }";
let first = declaration(source);
let second = declaration(source);
assert_eq!(first, second);
assert_eq!(
first.header.identity.insertion,
Some(source.find('{').unwrap())
);
let Body::Calculation { parameters, .. } = first.body else {
panic!()
};
let id = &parameters[0].identity;
assert!(id.value.is_none() && id.literal.is_none() && id.keyword.is_none());
assert_eq!(id.insertion, Some(source.find(':').unwrap()));
let explicit = "28ff40b0-1b8f-4735-8536-5a6ab21b56cb";
// Syntax does not add ParameterIds to a global identity registry, and an
// explicit parameter ID can equal its own calculation's global UUID.
for name in ["one", "two"] {
let source = format!(
"calculation {name} \"C\" id \"{explicit}\" {{ input a id \"{explicit}\" : Number; result Number; body $a; }}"
);
let actual = declaration(&source);
let Body::Calculation { parameters, .. } = actual.body else {
panic!()
};
assert_eq!(parameters[0].identity.value, actual.header.identity.value);
assert!(parameters[0].identity.insertion.is_none());
}
}
#[test]
fn inherited_version_root_and_short_word_contracts_do_not_enable_public8() {
let source = "oa_check c \"C\" { predicate unbound; }";
for version in [1, 2, 3, 4, 5, 6, 7, 9] {
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
let error = parse(
&mut Cursor::new(source, &tokens, &mut budget),
version,
None,
)
.unwrap_err();
assert_eq!(text(source, error.span), "oa_check");
assert!(error.message.contains("source8"));
}
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
assert!(
parse(
&mut Cursor::new(source, &tokens, &mut budget),
8,
Some(Id::nil())
)
.unwrap_err()
.message
.contains("roots")
);
for word in [
"oa_engineering_property",
"sa_engineering_property",
"oa_analysis_check",
"sa_analysis_check",
"oa_analysis_case",
"sa_analysis_case",
] {
assert!(declaration_kind(word).is_none());
}
for alias in [
"unit",
"calculation",
"quantity_kind",
"oa_property",
"if",
"and",
"List",
] {
let old = format!("arcadia 7; project {alias} \"P\" {{}}");
assert!(crate::language::parse(&old).is_ok(), "{old}");
}
let contextual =
declaration("calculation unit \"C\" { input and : Number; result Number; body $and; }");
assert_eq!(contextual.header.alias.name, "unit");
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
for version in 3..=7 {
assert!(
crate::language::parse(&format!(
"arcadia {version}; project p \"P\" {{ {source} }}"
))
.is_err()
);
}
}
#[test]
fn declaration_budgets_share_literals_types_references_and_failure_state() {
let source = "unit u \"U\" { kind @q; scale ratio(1, 3); offset ratio(2, 7); symbol \"u\"; }";
let mut measured = budget();
let tokens = lex::tokens(source, &mut measured).unwrap();
parse(&mut Cursor::new(source, &tokens, &mut measured), 8, None).unwrap();
let numeric = measured.numeric_units();
assert!(numeric > 0);
// All declaration, field and type entries share the same preallocation
// ceiling. Measure a deterministic valid counterpart before reducing it.
for (maximum, succeeds) in [
(measured.usage.entries, true),
(measured.usage.entries - 1, false),
] {
let mut profile = limits();
profile.entries = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let result = parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None);
assert_eq!(result.is_ok(), succeeds);
if !succeeds {
assert!(result.unwrap_err().message.contains("entries"));
}
}
for (maximum, succeeds) in [(numeric, true), (numeric - 1, false)] {
let mut profile = limits();
profile.numeric_units = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let result = parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None);
assert_eq!(result.is_ok(), succeeds);
}
let first =
"calculation c \"C\" { input x : Quantity(@q); result Quantity(@q); body unbound; }";
let second = "oa_property p \"P\" { subject @s; type Quantity(@q); base 1 [@u]; }";
let mut profile = limits();
profile.references = 4;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(first, &mut shared).unwrap();
parse(&mut Cursor::new(first, &tokens, &mut shared), 8, None).unwrap();
assert_eq!(shared.usage.references, 2);
let tokens = lex::tokens(second, &mut shared).unwrap();
let error = parse(&mut Cursor::new(second, &tokens, &mut shared), 8, None).unwrap_err();
assert_eq!(text(second, error.span), "u");
assert_eq!(shared.usage.references, 4);
assert_eq!(shared.active().unwrap_err(), error);
let numeric_after_failure = shared.numeric_units();
assert!(lex::tokens("1", &mut shared).is_err());
assert_eq!(shared.numeric_units(), numeric_after_failure);
let unbound_tokens = lex::tokens("unbound", &mut budget()).unwrap();
assert_eq!(
syntax::binding(&mut Cursor::new("unbound", &unbound_tokens, &mut shared)).unwrap_err(),
error
);
assert!(
declaration("oa_check fresh \"Fresh\" { predicate true; }")
.parent
.is_none()
);
}
#[test]
fn typed_frame_budget_and_truncated_declaration_cleanup_have_nearby_successes() {
let source = "calculation c \"C\" { result List(Quantity(@q)); body unbound; }";
for (maximum, succeeds) in [(3, true), (2, false)] {
let mut profile = limits();
profile.frames = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
assert_eq!(
parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None).is_ok(),
succeeds
);
}
let source = "oa_analysis a \"A\" { base ModelBaseline; overrides { @p = if(true, ratio(1, 2), @f(a: 1)); }; }";
let tokens = lex::tokens(source, &mut budget()).unwrap();
for token in tokens
.iter()
.filter(|token| matches!(text(source, token.span), "{" | "(" | "," | ":" | "="))
{
let prefix = &source[..token.span.end];
let mut shared = budget();
let tokens = lex::tokens(prefix, &mut shared).unwrap();
assert!(parse(&mut Cursor::new(prefix, &tokens, &mut shared), 8, None).is_err());
declaration("oa_check fresh \"Fresh\" { predicate true; }");
}
}
#[test]
fn typed_brace_frames_have_exact_zero_one_two_boundaries() {
let mut profile = limits();
profile.frames = 0;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens("unbound", &mut shared).unwrap();
let mut cursor = Cursor::new("unbound", &tokens, &mut shared);
assert!(matches!(
syntax::binding(&mut cursor).unwrap(),
Binding::Unbound(_)
));
assert_eq!(cursor.frames, 0);
assert_eq!(shared.usage.deepest_frame, 0);
for (source, needed) in [
("calculation c \"C\" { result Number; body unbound; }", 1),
("quantity_kind q \"Q\" { dimension {}; algebra Linear; }", 2),
(
"oa_analysis a \"A\" { base ModelBaseline; overrides {}; }",
2,
),
] {
for maximum in 0..=2 {
let mut profile = limits();
profile.frames = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let mut cursor = Cursor::new(source, &tokens, &mut shared);
let result = parse(&mut cursor, 8, None);
assert_eq!(
result.is_ok(),
maximum >= needed,
"{source}, limit {maximum}"
);
if let Err(error) = result {
assert!(error.message.contains("frames"));
assert_eq!(text(source, error.span), "{");
assert_eq!(
error.span.start,
if maximum == 0 {
source.find('{').unwrap()
} else {
source.rfind('{').unwrap()
}
);
} else {
assert_eq!(cursor.frames, 0);
cursor.end().unwrap();
assert_eq!(shared.usage.deepest_frame, needed);
}
}
}
}
#[test]
fn declaration_frames_restore_the_callers_inherited_context() {
let source = "calculation a \"A\" { result Number; body unbound; } calculation b \"B\" { result Number; body unbound; }";
for maximum in [1, 2] {
let mut profile = limits();
profile.frames = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let mut cursor = Cursor::new(source, &tokens, &mut shared);
// Future project/include integration owns this outer context. The
// declaration consumer must add to it, then restore it after closing.
cursor.frames = 1;
let first = parse(&mut cursor, 8, None);
if maximum == 1 {
let error = first.unwrap_err();
assert!(error.message.contains("frames"));
assert_eq!(text(source, error.span), "{");
} else {
assert!(first.is_ok());
assert_eq!(cursor.frames, 1);
parse(&mut cursor, 8, None).unwrap();
assert_eq!(cursor.frames, 1);
cursor.end().unwrap();
assert_eq!(shared.usage.deepest_frame, 2);
}
}
}
@@ -0,0 +1,709 @@
//! Temporary typed source witnesses. No arena index becomes a model identity.
//! The staged entry point parses each supplied typed file once. A future bundle
//! parser must retain the same opaque parse/lower provenance. Occurrence
//! completeness trusts that lowerer; the private builder does not prove the
//! presence of every syntax reference in a caller-fabricated Lowered value.
use super::cursor::Cursor;
use super::lower::{self, Child, Field, Lowered, Role, Target, Unit};
use super::{Budget, Error, Span, declarations, error, lex};
use crate::{Id, Project, analysis as a};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::Arc;
/// Persistent structural path: cloning shares ancestors, and equality/order
/// compare the complete edge sequence, never allocation or arena identities.
/// Depth is checked against the inherited frame limit before allocation.
/// Comparison and Debug walk leaf-to-root without allocating or recursing;
/// this private total order need not be root-first lexicographic order.
#[derive(Clone, Default)]
pub(super) struct Path(Option<Arc<Step>>);
struct Step {
parent: Path,
edge: Child,
}
impl PartialEq for Path {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for Path {}
impl PartialOrd for Path {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Path {
fn cmp(&self, other: &Self) -> Ordering {
let (mut left, mut right) = (self, other);
loop {
match (left.0.as_deref(), right.0.as_deref()) {
(Some(a), Some(b)) => {
let order = a.edge.cmp(&b.edge);
if order != Ordering::Equal {
return order;
}
(left, right) = (&a.parent, &b.parent);
}
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
}
}
}
}
impl fmt::Debug for Path {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
// Debug intentionally shows the same leaf-to-root order as comparison.
let mut list = formatter.debug_list();
let mut path = self;
while let Some(step) = &path.0 {
list.entry(&step.edge);
path = &step.parent;
}
list.finish()
}
}
impl Drop for Path {
fn drop(&mut self) {
let mut next = self.0.take();
while let Some(link) = next {
// Shared ancestors remain owned elsewhere. The final owner peels
// unique links here, taking the parent before Step is dropped.
let Some(mut step) = Arc::into_inner(link) else {
break;
};
next = step.parent.0.take();
}
}
}
impl Path {
pub fn edges(&self) -> Vec<Child> {
let mut result = Vec::new();
let mut path = self;
while let Some(step) = &path.0 {
result.push(step.edge);
path = &step.parent;
}
result.reverse();
result
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct ExpressionKey {
pub owner: Id,
pub field: Field,
pub path: Path,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum TokenRole {
Declaration,
Use(Role),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct OccurrenceKey {
pub owner: Id,
/// None is a global declaration header, not a binding field.
pub field: Option<Field>,
/// None is outside an expression; Some(empty) is its root call site.
pub path: Option<Path>,
pub role: TokenRole,
pub target: Target,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Location {
pub file: usize,
pub span: Span,
}
#[derive(Debug)]
pub(super) struct Index<'s> {
// Private provenance: callers can inspect locations, not supply replacements
// for the source witness, keys, catalog or occurrence spans.
sources: &'s BTreeMap<usize, String>,
names: BTreeMap<Target, String>,
occurrences: BTreeMap<OccurrenceKey, Location>,
expressions: BTreeMap<ExpressionKey, Location>,
}
impl<'s> Index<'s> {
pub fn sources(&self) -> &BTreeMap<usize, String> {
self.sources
}
pub fn names(&self) -> &BTreeMap<Target, String> {
&self.names
}
pub fn occurrences(&self) -> &BTreeMap<OccurrenceKey, Location> {
&self.occurrences
}
pub fn expressions(&self) -> &BTreeMap<ExpressionKey, Location> {
&self.expressions
}
/// Typed-only staged adapter. `ordinary` has already been parsed by its
/// owner. Missing typed IDs fail in lowering; this never invents UUIDs.
/// The caller merges the returned additions and admits the complete model.
pub fn parse(
ordinary: &Project,
sources: &'s BTreeMap<usize, String>,
budget: &mut Budget,
) -> Result<(Self, Lowered), lower::Failure> {
let mut units = Vec::new();
let mut witnesses = BTreeMap::new();
for (&file, source) in sources {
let failure = |error| lower::Failure {
file: Some(file),
error,
};
budget
.lowering_entries(units.len() + 1, Span::default())
.map_err(failure)?;
let tokens = lex::tokens(source, budget).map_err(failure)?;
let mut cursor = Cursor::new(source, &tokens, budget);
let mut parsed = Vec::new();
while cursor.cursor < tokens.len() {
parsed.push(declarations::parse(&mut cursor, 8, None).map_err(failure)?);
}
units.push(Unit {
file,
declarations: parsed,
});
witnesses.insert(file, tokens);
}
let lowered = lower::lower(ordinary, &units, budget)?;
// A build failure after our own parse/lower is a phase/invariant error,
// not an invented file assignment for an ambiguous numeric span.
let index = Self::build(ordinary, sources, &units, &witnesses, &lowered, budget)
.map_err(|error| lower::Failure { file: None, error })?;
Ok((index, lowered))
}
// Same-parse/lower internal seam only. Checks below reject damaged supplied
// sites but occurrence completeness is an invariant of our own lowerer.
// Do not expose a constructor accepting independently fabricated Lowered.
fn build(
ordinary: &Project,
sources: &'s BTreeMap<usize, String>,
units: &[Unit],
witnesses: &BTreeMap<usize, Vec<lex::Token>>,
lowered: &Lowered,
budget: &mut Budget,
) -> Result<Self, Error> {
budget.active()?;
budget.lowering_entries(lowered.expressions.len(), Span::default())?;
budget.lowering_occurrences(lowered.occurrences.len(), Span::default())?;
let mut names = BTreeMap::new();
let mut name_bytes = 0usize;
let mut insert_name = |target, name: &str| -> Result<(), Error> {
budget.lowering_entries(names.len() + 1, Span::default())?;
name_bytes = name_bytes
.checked_add(name.len())
.ok_or_else(|| error(Span::default(), "Index name size overflow."))?;
budget.index_bytes(name_bytes, Span::default())?;
if names.insert(target, name.to_owned()).is_some() {
return Err(error(
Span::default(),
"Duplicate qualified catalog identity.",
));
}
Ok(())
};
// Lowering has already bounded and validated the complete ordinary
// identity catalog before this existing convenience visitor allocates.
for (id, alias) in ordinary.identities() {
insert_name(Target::Global(id), alias)?;
}
for definition in ordinary
.definitions
.values()
.chain(lowered.definitions.values())
{
if lowered.definitions.contains_key(&definition.id) {
insert_name(Target::Global(definition.id), &definition.alias)?;
}
if let a::DefinitionBody::Calculation(calculation) = &definition.body {
for parameter in &calculation.parameters {
insert_name(
Target::Parameter {
calculation: definition.id,
parameter: parameter.id,
},
&parameter.name,
)?;
}
}
}
for element in lowered.elements.values() {
insert_name(Target::Global(element.id), &element.alias)?;
}
let mut index = Self {
sources,
names,
occurrences: BTreeMap::new(),
expressions: BTreeMap::new(),
};
let mut owners = BTreeMap::new();
let mut physical = BTreeSet::new();
for unit in units {
for declaration in &unit.declarations {
let owner = declaration.header.identity.value.ok_or_else(|| {
error(
declaration.span,
"Missing ID requires source stabilization.",
)
})?;
let location = Location {
file: unit.file,
span: declaration.span,
};
checked_source(sources, location)?;
budget.lowering_entries(owners.len() + 1, declaration.span)?;
if owners.insert(owner, location).is_some() {
return Err(error(declaration.span, "Duplicate source owner."));
}
let key = OccurrenceKey {
owner,
field: None,
path: None,
role: TokenRole::Declaration,
target: Target::Global(owner),
};
index.insert(
key,
Location {
file: unit.file,
span: declaration.header.alias.span,
},
witnesses,
&mut physical,
budget,
)?;
}
}
// Parent-before-child is part of the lowering contract. One forward
// pass rejects forward, cyclic, cross-file/owner/field and duplicate
// paths. Each node allocates one shared edge, not a copied path vector.
let mut paths: Vec<(ExpressionKey, usize)> = Vec::new();
for (position, site) in lowered.expressions.iter().enumerate() {
if !matches!(
site.field,
Field::Body | Field::Base | Field::Predicate | Field::Override(_)
) {
return Err(error(
site.span,
"Expression occurs outside a binding field.",
));
}
let location = Location {
file: site.file,
span: site.span,
};
checked_source(sources, location)?;
let owner = owners
.get(&site.owner)
.ok_or_else(|| error(site.span, "Unknown expression owner."))?;
contained(*owner, location)?;
let (path, depth) = if let Some((parent, edge)) = site.parent {
if parent >= position {
return Err(error(
site.span,
"Expression parent must precede its child.",
));
}
let ancestor = &lowered.expressions[parent];
if ancestor.owner != site.owner
|| ancestor.field != site.field
|| ancestor.file != site.file
{
return Err(error(
site.span,
"Expression parent crosses its owner, field or file.",
));
}
contained(
Location {
file: ancestor.file,
span: ancestor.span,
},
location,
)?;
if ancestor.span == site.span {
return Err(error(
site.span,
"Child expression must be a proper source subtree.",
));
}
let depth = paths[parent].1 + 1;
budget.frame(depth, site.span)?;
(
Path(Some(Arc::new(Step {
parent: paths[parent].0.path.clone(),
edge,
}))),
depth,
)
} else {
budget.frame(1, site.span)?;
(Path::default(), 1)
};
let key = ExpressionKey {
owner: site.owner,
field: site.field,
path,
};
if index.expressions.insert(key.clone(), location).is_some() {
return Err(error(site.span, "Duplicate qualified expression path."));
}
paths.push((key, depth));
}
for occurrence in &lowered.occurrences {
let location = Location {
file: occurrence.file,
span: occurrence.span,
};
let owner = owners
.get(&occurrence.owner)
.ok_or_else(|| error(occurrence.span, "Unknown occurrence owner."))?;
contained(*owner, location)?;
let path = match occurrence.expression {
Some(site) => {
let expression = lowered
.expressions
.get(site)
.ok_or_else(|| error(occurrence.span, "Unknown occurrence expression."))?;
if expression.owner != occurrence.owner
|| expression.field != occurrence.field
|| expression.file != occurrence.file
{
return Err(error(
occurrence.span,
"Occurrence crosses its expression owner, field or file.",
));
}
contained(
Location {
file: expression.file,
span: expression.span,
},
location,
)?;
Some(paths[site].0.path.clone())
}
None => None,
};
match (occurrence.role, occurrence.target) {
(
Role::ParameterDeclaration,
Target::Parameter {
calculation,
parameter,
},
) if calculation == occurrence.owner
&& occurrence.field == Field::Input(parameter)
&& path.is_none() => {}
(Role::ParameterUse, Target::Parameter { calculation, .. })
if calculation == occurrence.owner
&& occurrence.field == Field::Body
&& path.is_some() => {}
(Role::ArgumentLabel, Target::Parameter { .. }) if path.is_some() => {}
(Role::OverrideTarget, Target::Global(id))
if occurrence.field == Field::Override(id) && path.is_none() => {}
(Role::CallTarget, Target::Global(_)) if path.is_some() => {}
(Role::Reference, Target::Global(_)) => {}
_ => return Err(error(occurrence.span, "Invalid qualified occurrence role.")),
}
let key = OccurrenceKey {
owner: occurrence.owner,
field: Some(occurrence.field),
path,
role: TokenRole::Use(occurrence.role),
target: occurrence.target,
};
index.insert(key, location, witnesses, &mut physical, budget)?;
}
// Argument labels belong to this precise call site and its callee, not
// merely to a parameter UUID/name present somewhere in the catalog.
let mut calls = BTreeMap::new();
for (key, at) in &index.occurrences {
if key.role == TokenRole::Use(Role::CallTarget) {
let site = (key.owner, key.field, key.path.clone());
if calls.insert(site, key.target).is_some() {
return Err(error(at.span, "Multiple callees at one expression site."));
}
}
}
for (key, at) in &index.occurrences {
if let (TokenRole::Use(Role::ArgumentLabel), Target::Parameter { calculation, .. }) =
(key.role, key.target)
&& calls.get(&(key.owner, key.field, key.path.clone()))
!= Some(&Target::Global(calculation))
{
return Err(error(
at.span,
"Argument label crosses its called calculation.",
));
}
}
for (position, site) in lowered.expressions.iter().enumerate() {
if let Some((parent, Child::Argument(parameter))) = site.parent {
let key = &paths[parent].0;
let Some(Target::Global(calculation)) =
calls.get(&(key.owner, Some(key.field), Some(key.path.clone())))
else {
return Err(error(site.span, "Argument child has no owning call."));
};
let label = OccurrenceKey {
owner: site.owner,
field: Some(site.field),
path: Some(key.path.clone()),
role: TokenRole::Use(Role::ArgumentLabel),
target: Target::Parameter {
calculation: *calculation,
parameter,
},
};
if !index.occurrences.contains_key(&label) || paths[position].0.path == key.path {
return Err(error(site.span, "Argument child has no qualified label."));
}
}
}
index.check_syntax_edges(units, lowered, budget)?;
Ok(index)
}
/// Check structural edges against the already parsed syntax, not just the
/// arena's internally consistent claims. This is one iterative tree walk,
/// with indexed span lookup; it does not lex or copy source/path strings.
fn check_syntax_edges<'a>(
&self,
units: &'a [Unit],
lowered: &Lowered,
budget: &Budget,
) -> Result<(), Error> {
let mut globals = BTreeMap::new();
let mut parameters = BTreeMap::new();
for (target, name) in &self.names {
match target {
Target::Global(id) => {
globals.insert(name.as_str(), *id);
}
Target::Parameter {
calculation,
parameter,
} => {
parameters.insert((*calculation, name.as_str()), *parameter);
}
}
}
let mut sites = BTreeMap::new();
for (position, site) in lowered.expressions.iter().enumerate() {
if sites
.insert(
(
site.file,
site.owner,
site.field,
site.span.start,
site.span.end,
),
position,
)
.is_some()
{
return Err(error(site.span, "Duplicate expression source ownership."));
}
}
let mut work = Vec::new();
let mut root = |file, owner, field, binding: &'a super::Binding| -> Result<(), Error> {
if let super::Binding::Expression(expression) = binding {
budget.lowering_entries(work.len() + 1, expression.span)?;
work.push((file, owner, field, expression, None));
}
Ok(())
};
for unit in units {
for declaration in &unit.declarations {
let owner = declaration.header.identity.value.expect("validated owner");
match &declaration.body {
declarations::Body::Calculation { body, .. } => {
root(unit.file, owner, Field::Body, &body.value)?
}
declarations::Body::Property { base, .. } => {
root(unit.file, owner, Field::Base, &base.value)?
}
declarations::Body::Check { predicate, .. } => {
root(unit.file, owner, Field::Predicate, &predicate.value)?
}
declarations::Body::Case { overrides, .. } => {
for entry in &overrides.value {
let target =
*globals.get(entry.target.name.as_str()).ok_or_else(|| {
error(entry.target.span, "Unknown syntax override target.")
})?;
root(unit.file, owner, Field::Override(target), &entry.binding)?;
}
}
_ => {}
}
}
}
let mut visited = 0usize;
while let Some((file, owner, field, expression, parent)) = work.pop() {
budget.lowering_entries(visited + 1, expression.span)?;
visited += 1;
let site = *sites
.get(&(
file,
owner,
field,
expression.span.start,
expression.span.end,
))
.ok_or_else(|| {
error(
expression.span,
"Parsed expression is absent from the source arena.",
)
})?;
if lowered.expressions[site].parent != parent {
return Err(error(
expression.span,
"Arena parent/child edge differs from original syntax.",
));
}
let mut child = |value: &'a super::Expression, edge| -> Result<(), Error> {
budget.lowering_entries(work.len() + 1, value.span)?;
work.push((file, owner, field, value, Some((site, edge))));
Ok(())
};
use super::ExpressionKind as E;
match &expression.kind {
E::List(items) => {
for (index, item) in items.iter().enumerate() {
child(item, Child::Item(index))?;
}
}
E::Unary { operand, .. } => child(operand, Child::Operand)?,
E::Binary { left, right, .. } => {
child(left, Child::Left)?;
child(right, Child::Right)?;
}
E::If {
condition,
when_true,
when_false,
} => {
child(condition, Child::Condition)?;
child(when_true, Child::WhenTrue)?;
child(when_false, Child::WhenFalse)?;
}
E::Call {
definition,
arguments,
} => {
let calculation = *globals
.get(definition.name.as_str())
.ok_or_else(|| error(definition.span, "Unknown syntax call target."))?;
for argument in arguments {
let parameter = *parameters
.get(&(calculation, argument.parameter.name.as_str()))
.ok_or_else(|| {
error(argument.parameter.span, "Unknown syntax call parameter.")
})?;
child(&argument.value, Child::Argument(parameter))?;
}
}
E::Sum { values, initial } => {
child(values, Child::Values)?;
child(initial, Child::Initial)?;
}
E::Power { base, .. } => child(base, Child::Base)?,
E::AsKind { value, .. } => child(value, Child::Value)?,
_ => {}
}
}
if visited != lowered.expressions.len() {
return Err(error(
Span::default(),
"Arena contains expressions absent from parsed syntax.",
));
}
Ok(())
}
fn insert(
&mut self,
key: OccurrenceKey,
location: Location,
witnesses: &BTreeMap<usize, Vec<lex::Token>>,
physical: &mut BTreeSet<(usize, usize, usize)>,
budget: &Budget,
) -> Result<(), Error> {
budget.lowering_occurrences(self.occurrences.len() + 1, location.span)?;
let text = checked_source(self.sources, location)?;
if self.names.get(&key.target).is_none_or(|name| name != text) {
return Err(error(
location.span,
"Token differs from its qualified target name.",
));
}
let tokens = witnesses
.get(&location.file)
.ok_or_else(|| error(location.span, "Missing lexical witness."))?;
let token = tokens
.binary_search_by_key(&location.span.start, |token| token.span.start)
.ok()
.map(|at| tokens[at]);
if token.is_none_or(|token| token.span != location.span || token.class != lex::Class::Word)
{
return Err(error(
location.span,
"Occurrence is not an original whole name token.",
));
}
if self.occurrences.contains_key(&key)
|| !physical.insert((location.file, location.span.start, location.span.end))
{
return Err(error(
location.span,
"Duplicate qualified occurrence or token ownership.",
));
}
self.occurrences.insert(key, location);
Ok(())
}
}
pub(super) fn checked_source(
sources: &BTreeMap<usize, String>,
location: Location,
) -> Result<&str, Error> {
if location.span.start >= location.span.end {
return Err(error(location.span, "Empty or reversed source span."));
}
sources
.get(&location.file)
.and_then(|source| source.get(location.span.start..location.span.end))
.ok_or_else(|| {
error(
location.span,
"Source span is outside its original UTF-8 file.",
)
})
}
fn contained(parent: Location, child: Location) -> Result<(), Error> {
if parent.file != child.file
|| child.span.start < parent.span.start
|| child.span.end > parent.span.end
{
return Err(error(
child.span,
"Source span crosses its containing syntax.",
));
}
Ok(())
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,630 @@
use super::*;
use crate::language::ParseLimits;
const OA: &str = include_str!("../lower/fixtures/typed-oa.arcadia");
const ORDINARY: &str = include_str!("../lower/fixtures/ordinary-oa.arcadia");
fn budget() -> Budget {
Budget::new(super::super::budget::Limits::source8(
ParseLimits::UNLIMITED,
))
.unwrap()
}
fn ordinary() -> Project {
crate::language::parse(ORDINARY).unwrap()
}
fn sources() -> BTreeMap<usize, String> {
let split = OA.find("calculation combine").unwrap();
BTreeMap::from([
(7, OA[..split].replace('\n', "\r\n")),
(19, OA[split..].to_owned()),
])
}
fn raw(
sources: &BTreeMap<usize, String>,
budget: &mut Budget,
) -> (Vec<Unit>, BTreeMap<usize, Vec<lex::Token>>, Lowered) {
let mut units = Vec::new();
let mut witnesses = BTreeMap::new();
for (&file, source) in sources {
let tokens = lex::tokens(source, budget).unwrap();
let mut cursor = Cursor::new(source, &tokens, budget);
let mut declarations = Vec::new();
while cursor.cursor < tokens.len() {
declarations.push(declarations::parse(&mut cursor, 8, None).unwrap());
}
units.push(Unit { file, declarations });
witnesses.insert(file, tokens);
}
let lowered = lower::lower(&ordinary(), &units, budget).unwrap();
(units, witnesses, lowered)
}
#[test]
fn staged_index_preserves_complete_oa_sa_models_and_original_file_spans() {
for (layer, ordinary_source, typed) in [
(crate::Layer::Oa, ORDINARY, OA),
(
crate::Layer::Sa,
include_str!("../lower/fixtures/ordinary-sa.arcadia"),
include_str!("../lower/fixtures/typed-sa.arcadia"),
),
] {
let sources = BTreeMap::from([(37, typed.replace('\n', "\r\n"))]);
let skeleton = crate::language::parse(ordinary_source).unwrap();
let mut work = budget();
let (index, lowered) = Index::parse(&skeleton, &sources, &mut work).unwrap();
let mut actual = skeleton;
actual.definitions.extend(lowered.definitions);
actual.elements.extend(lowered.elements);
assert_eq!(actual, super::super::model_oracle::project(layer));
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
assert_eq!(work.usage.bytes, sources[&37].len(), "one lexical pass");
for (key, at) in index.occurrences() {
assert_eq!(at.file, 37);
assert_eq!(
&sources[&37][at.span.start..at.span.end],
index.names[&key.target]
);
}
assert!(!index.expressions().is_empty());
}
}
#[test]
fn repeated_reordered_calls_have_stable_full_paths_and_four_labels() {
let expression = "@combine(b: @dispatch_time, a: @receive_time) // first 雪\n + @combine(a: @receive_time, b: @dispatch_time)";
let original = OA.replace("@combine(a: @receive_time, b: @dispatch_time)", expression);
let reordered = original.replace(
"@combine(b: @dispatch_time, a: @receive_time)",
"@combine(a: @receive_time, b: @dispatch_time)",
);
let first = BTreeMap::from([(7, original)]);
let second = BTreeMap::from([(7, reordered)]);
let (left, _) = Index::parse(&ordinary(), &first, &mut budget()).unwrap();
let (right, _) = Index::parse(&ordinary(), &second, &mut budget()).unwrap();
assert_eq!(
left.occurrences.keys().collect::<Vec<_>>(),
right.occurrences.keys().collect::<Vec<_>>()
);
assert_eq!(
left.expressions.keys().collect::<Vec<_>>(),
right.expressions.keys().collect::<Vec<_>>()
);
let labels: Vec<_> = left
.occurrences
.iter()
.filter(|(key, _)| key.role == TokenRole::Use(Role::ArgumentLabel))
.collect();
assert_eq!(labels.len(), 4);
let paths: BTreeSet<_> = labels
.iter()
.map(|(key, _)| key.path.as_ref().unwrap().edges())
.collect();
assert_eq!(
paths,
BTreeSet::from([vec![Child::Left], vec![Child::Right]])
);
let spans: BTreeSet<_> = labels
.iter()
.map(|(_, at)| (at.span.start, at.span.end))
.collect();
assert_eq!(spans.len(), 4);
}
#[test]
fn index_checks_every_expression_edge_and_keeps_override_fields_distinct() {
let source = OA
.replace("body $a + $b;", "body if(if(true, 1 < 2, false), sum([$a, abs(-$b)], 0 [@second]), as_kind(@duration, power(1 [@second], 2) / 1 [@second]));")
.replace("@receive_time = 1250 [@millisecond];", "@receive_time = @combine(a: 1 [@second], b: 2 [@second]); @dispatch_time = @combine(b: 2 [@second], a: 1 [@second]);");
let sources = BTreeMap::from([(17, source)]);
let (index, lowered) = Index::parse(&ordinary(), &sources, &mut budget()).unwrap();
let mut project = ordinary();
project.definitions.extend(lowered.definitions);
project.elements.extend(lowered.elements);
a::admit(&project, a::AdmissionLimits::REFERENCE).unwrap();
let edges: BTreeSet<_> = index
.expressions
.keys()
.flat_map(|key| key.path.edges())
.collect();
for edge in [
Child::Item(0),
Child::Item(1),
Child::Operand,
Child::Left,
Child::Right,
Child::Condition,
Child::WhenTrue,
Child::WhenFalse,
Child::Values,
Child::Initial,
Child::Base,
Child::Value,
] {
assert!(edges.contains(&edge), "missing {edge:?}");
}
assert!(edges.iter().any(|edge| matches!(edge, Child::Argument(_))));
let labels: Vec<_> = index
.occurrences
.keys()
.filter(|key| {
key.role == TokenRole::Use(Role::ArgumentLabel)
&& matches!(key.field, Some(Field::Override(_)))
})
.collect();
assert_eq!(labels.len(), 4);
assert_eq!(
labels
.iter()
.map(|key| key.field)
.collect::<BTreeSet<_>>()
.len(),
2
);
assert!(
labels
.iter()
.all(|key| key.path.as_ref().unwrap().edges().is_empty())
);
}
#[test]
fn arena_rejects_forward_cycles_and_cross_owner_field_file_parents() {
for damage in 0..6 {
let sources = sources();
let mut work = budget();
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
let child = lowered
.expressions
.iter()
.position(|site| site.parent.is_some())
.unwrap();
let parent = lowered.expressions[child].parent.unwrap().0;
match damage {
0 => lowered.expressions[child].parent = Some((child, Child::Left)),
1 => lowered.expressions[parent].parent = Some((child, Child::Right)),
2 => lowered.expressions[child].owner = ordinary().id,
3 => lowered.expressions[child].field = Field::Predicate,
4 => lowered.expressions[child].file = 12345,
_ => lowered.expressions[child].parent = Some((parent, Child::Operand)),
}
assert!(
Index::build(
&ordinary(),
&sources,
&units,
&witnesses,
&lowered,
&mut work
)
.is_err(),
"damage {damage}"
);
}
}
#[test]
fn arena_rejects_duplicate_sites_occurrences_and_wrong_callee_labels() {
for damage in 0..4 {
let mut sources = sources();
// The other callee is fully valid and reuses both parameter identities
// and spellings. Catalog/token validation must not explain rejection.
sources.insert(
31,
r#"calculation other "Other" id "00000000-0000-0000-0000-00000000cafe" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
result Quantity(@duration); body $a + $b;
}"#
.into(),
);
let mut work = budget();
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
let valid = Index::build(
&ordinary(),
&sources,
&units,
&witnesses,
&lowered,
&mut work,
)
.unwrap();
let mut expected_mismatch_span = None;
match damage {
0 => {
let site = &lowered.expressions[0];
lowered.expressions.push(lower::ExpressionSite {
file: site.file,
owner: site.owner,
field: site.field,
parent: site.parent,
span: site.span,
});
}
1 => {
let row = &lowered.occurrences[0];
lowered.occurrences.push(lower::Occurrence {
file: row.file,
owner: row.owner,
field: row.field,
expression: row.expression,
role: row.role,
target: row.target,
span: row.span,
});
}
2 => {
let label = lowered
.occurrences
.iter_mut()
.find(|row| row.role == Role::ArgumentLabel)
.unwrap();
let original = label.target;
let Target::Parameter {
calculation,
parameter,
} = &mut label.target
else {
panic!("argument label must target a parameter");
};
let other = Target::Parameter {
calculation: Id::from_u128(0xcafe),
parameter: *parameter,
};
assert_eq!(valid.names[&original], valid.names[&other]);
*calculation = Id::from_u128(0xcafe);
expected_mismatch_span = Some(label.span);
}
_ => {
let row = lowered
.occurrences
.iter_mut()
.find(|row| row.role == Role::OverrideTarget)
.unwrap();
row.field = Field::Override(ordinary().id);
}
}
let failure = Index::build(
&ordinary(),
&sources,
&units,
&witnesses,
&lowered,
&mut work,
)
.unwrap_err();
if let Some(span) = expected_mismatch_span {
assert_eq!(
failure.message,
"Argument label crosses its called calculation."
);
assert_eq!(failure.span, span);
}
}
}
#[test]
fn index_rejects_comment_partial_and_non_utf8_occurrence_spans() {
for damage in 0..3 {
let mut sources = sources();
let with_comment =
sources[&7].replace("subject @receive;", "subject @receive; // duration 雪\r\n");
sources.insert(7, with_comment);
let mut work = budget();
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
let occurrence = lowered
.occurrences
.iter_mut()
.find(|row| row.file == 7 && &sources[&7][row.span.start..row.span.end] == "duration")
.unwrap();
match damage {
0 => {
let start = sources[&7].find("// duration").unwrap() + 3;
occurrence.span = Span {
start,
end: start + 8,
};
}
1 => occurrence.span.end -= 1,
_ => {
let start = sources[&7].find('雪').unwrap() + 1;
occurrence.span = Span {
start,
end: start + 1,
};
}
}
assert!(
Index::build(
&ordinary(),
&sources,
&units,
&witnesses,
&lowered,
&mut work
)
.is_err()
);
}
}
#[test]
fn index_has_shared_budget_and_never_stabilizes_missing_ids() {
let source = "calculation f \"F\" { result Number; body unbound; }";
let files = BTreeMap::from([(91, source.into())]);
let missing = Index::parse(&ordinary(), &files, &mut budget()).unwrap_err();
assert_eq!(missing.file, Some(91));
assert!(missing.error.message.contains("stabilization"));
let mut limits = super::super::budget::Limits::source8(ParseLimits::UNLIMITED);
limits.bytes = 1;
let mut work = Budget::new(limits).unwrap();
assert!(Index::parse(&ordinary(), &sources(), &mut work).is_err());
assert!(work.active().is_err());
}
fn on_small_stack(work: impl FnOnce() + Send + 'static) {
// Match the established core language_nesting_tests worker-stack request.
// This is a requested platform stack size, not a measured peak-byte claim.
std::thread::Builder::new()
.name("analysis-index-128k".into())
.stack_size(128 * 1024)
.spawn(work)
.unwrap()
.join()
.unwrap();
}
fn chain(first: Child, edges: usize) -> Path {
let mut path = Path::default();
for position in 0..edges {
path = Path(Some(Arc::new(Step {
parent: path,
edge: if position == 0 { first } else { Child::Operand },
})));
}
path
}
#[test]
fn maximum_index_paths_compare_lookup_debug_and_drop_on_128k_stack() {
on_small_stack(|| {
let frames = super::super::budget::Limits::source8(ParseLimits::UNLIMITED).frames;
assert_eq!(frames, 256);
let edges = frames - 1; // root has frame depth one
let mut work = budget();
work.frame(frames, Span::default()).unwrap();
let left = chain(Child::Left, edges);
let equal = chain(Child::Left, edges); // independent allocations
let right = chain(Child::Right, edges);
assert!(!Arc::ptr_eq(
left.0.as_ref().unwrap(),
equal.0.as_ref().unwrap()
));
assert_eq!(left, equal);
assert_eq!(left.partial_cmp(&equal), Some(Ordering::Equal));
// The difference is at the root-most edge: leaf-first walks every link.
assert_eq!(left.cmp(&right), Child::Left.cmp(&Child::Right));
assert_eq!(right.cmp(&left), left.cmp(&right).reverse());
assert!(Path::default() < left);
assert!(chain(Child::Operand, edges - 1) < chain(Child::Operand, edges));
let shared = left.clone();
assert!(Arc::ptr_eq(
left.0.as_ref().unwrap(),
shared.0.as_ref().unwrap()
));
let leaf = Arc::downgrade(left.0.as_ref().unwrap());
let key = |path| ExpressionKey {
owner: Id::from_u128(1),
field: Field::Body,
path,
};
let mut expressions = BTreeMap::new();
assert_eq!(expressions.insert(key(left.clone()), 1), None);
assert_eq!(expressions.insert(key(equal.clone()), 2), Some(1));
assert_eq!(expressions.insert(key(right.clone()), 3), None);
assert_eq!(expressions.get(&key(equal.clone())), Some(&2));
assert_eq!(expressions.get(&key(chain(Child::Right, edges))), Some(&3));
assert_eq!(expressions.len(), 2);
let occurrence = |path| OccurrenceKey {
owner: Id::from_u128(1),
field: Some(Field::Body),
path: Some(path),
role: TokenRole::Use(Role::ParameterUse),
target: Target::Parameter {
calculation: Id::from_u128(1),
parameter: a::ParameterId(Id::from_u128(2)),
},
};
let mut occurrences = BTreeSet::new();
assert!(occurrences.insert(occurrence(left.clone())));
assert!(!occurrences.insert(occurrence(equal.clone())));
assert!(occurrences.contains(&occurrence(chain(Child::Left, edges))));
let debug = format!("{left:?}");
assert_eq!(debug.matches("Operand").count(), edges - 1);
assert!(debug.ends_with("Left]"));
assert!(!format!("{expressions:?} {occurrences:?}").is_empty());
drop(expressions);
drop(occurrences);
drop(shared);
drop(left);
assert!(leaf.upgrade().is_none(), "maps/clones release their chain");
drop(equal);
drop(right);
// Dropping a child stops at a shared ancestor, which remains usable.
let parent = chain(Child::Left, edges - 1);
let ancestor = Arc::downgrade(parent.0.as_ref().unwrap());
let child = Path(Some(Arc::new(Step {
parent: parent.clone(),
edge: Child::Right,
})));
drop(child);
assert_eq!(parent.edges().len(), edges - 1);
drop(parent);
assert!(ancestor.upgrade().is_none());
// No other strong owners: final destruction must peel the full chain.
let unique = chain(Child::Left, edges);
let final_leaf = Arc::downgrade(unique.0.as_ref().unwrap());
drop(unique);
assert!(final_leaf.upgrade().is_none());
});
}
#[test]
fn deep_parse_index_plan_and_failed_build_cleanup_on_128k_stack() {
on_small_stack(|| {
use super::super::update::Plan;
let deep = format!("{}$a{}", "abs(".repeat(127), ")".repeat(127));
let source = format!(
r#"calculation f "Deep 雪" id "00000000-0000-0000-0000-000000000081" {{
input a id "00000000-0000-0000-0000-000000000083" : Number;
result Number; body {deep};
}}
calculation caller "Caller" id "00000000-0000-0000-0000-000000000082" {{
result Number; body @f(a: 1);
}}
"#
);
let sources = BTreeMap::from([(47, source.clone())]);
let skeleton = ordinary();
let mut work = budget();
let (index, lowered) = Index::parse(&skeleton, &sources, &mut work).unwrap();
assert_eq!(work.usage.bytes, source.len());
assert_eq!(index.expressions.len(), 130); // 128 unary/leaf + call/argument
assert_eq!(
index.expressions.keys().map(|k| k.path.edges().len()).max(),
Some(127)
);
let renames = BTreeMap::from([
(Target::Global(Id::from_u128(0x81)), "renamed_deep".into()),
(
Target::Parameter {
calculation: Id::from_u128(0x81),
parameter: a::ParameterId(Id::from_u128(0x83)),
},
"renamed_arg".into(),
),
]);
let usage = work.usage;
let plan = Plan::renames(&index, &renames, &work).unwrap();
assert_eq!(plan.edit_count(), 5); // declaration/call + input/use/label
let output = plan.apply(&sources, &work).unwrap();
assert_eq!(work.usage, usage);
// Independent complete original-token oracle; no index-derived offsets.
let expected_source = source
.replace("calculation f ", "calculation renamed_deep ")
.replace("input a id", "input renamed_arg id")
.replace("$a", "$renamed_arg")
.replace("@f(a: 1)", "@renamed_deep(renamed_arg: 1)");
assert_eq!(output, BTreeMap::from([(47, expected_source)]));
let (renamed_index, renamed) = Index::parse(&skeleton, &output, &mut budget()).unwrap();
assert_eq!(
index.occurrences.keys().collect::<Vec<_>>(),
renamed_index.occurrences.keys().collect::<Vec<_>>()
);
// Compare complete independently constructed models, including deepest
// parameter identity and the call's parameter-keyed argument map.
for (lowered, renamed) in [(lowered, false), (renamed, true)] {
let mut expected = skeleton.clone();
let parameter = a::ParameterId(Id::from_u128(0x83));
let mut expression = a::Expr::Parameter(parameter);
for _ in 0..127 {
expression = a::Expr::Unary {
op: a::UnaryOp::Absolute,
operand: Box::new(expression),
};
}
for (id, alias, name, parameters, expression) in [
(
0x81,
if renamed { "renamed_deep" } else { "f" },
"Deep 雪",
vec![a::Parameter {
id: parameter,
name: if renamed { "renamed_arg" } else { "a" }.into(),
value_type: a::AnalysisType::Scalar(a::ScalarType::Number),
}],
expression,
),
(
0x82,
"caller",
"Caller",
Vec::new(),
a::Expr::Call {
definition: a::DefinitionRef::Local(Id::from_u128(0x81)),
arguments: BTreeMap::from([(
parameter,
a::Expr::Number(crate::analysis_numeric::Exact::from_integer(1)),
)]),
},
),
] {
let id = Id::from_u128(id);
expected.definitions.insert(
id,
a::Definition {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
body: a::DefinitionBody::Calculation(a::CalculationDefinition {
parameters,
result: a::AnalysisType::Scalar(a::ScalarType::Number),
body: a::Binding::Expression(expression),
}),
},
);
}
let mut actual = skeleton.clone();
actual.definitions.extend(lowered.definitions);
actual.elements.extend(lowered.elements);
assert_eq!(actual, expected);
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
drop(actual);
drop(expected);
}
drop(plan);
drop(renamed_index);
drop(index);
// Failure after the deepest keys have been allocated must clean up on
// the same stack; no modified Lowered escapes this private test seam.
let mut work = budget();
let (units, witnesses, mut lowered) = raw(&sources, &mut work);
let deepest = lowered
.expressions
.iter()
.find(|site| &sources[&site.file][site.span.start..site.span.end] == "$a")
.unwrap();
lowered.expressions.push(lower::ExpressionSite {
file: deepest.file,
owner: deepest.owner,
field: deepest.field,
parent: deepest.parent,
span: deepest.span,
});
let failure =
Index::build(&skeleton, &sources, &units, &witnesses, &lowered, &mut work).unwrap_err();
assert_eq!(failure.message, "Duplicate qualified expression path.");
lowered.expressions.pop();
let mut limits = super::super::budget::Limits::source8(ParseLimits::UNLIMITED);
limits.frames = 127;
let mut bounded = Budget::new(limits).unwrap();
let failure = Index::build(
&skeleton,
&sources,
&units,
&witnesses,
&lowered,
&mut bounded,
)
.unwrap_err();
assert!(failure.message.contains("syntax frames"));
assert!(bounded.active().is_err());
drop(lowered);
drop(witnesses);
drop(units);
let rejected = BTreeMap::from([(47, source.replace(&deep, &format!("abs({deep})")))]);
let failure = Index::parse(&skeleton, &rejected, &mut budget()).unwrap_err();
assert!(failure.error.message.contains("logical depth"));
});
}
@@ -0,0 +1,52 @@
//! Source8 significant-token adapter over the shared raw scanner.
use super::super::raw_tokens::{Class as RawClass, Scanner};
use super::{Budget, Error, Span, error};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Class {
Word,
Number,
String,
Punctuation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Token {
pub class: Class,
pub span: Span,
}
pub fn tokens(source: &str, budget: &mut Budget) -> Result<Vec<Token>, Error> {
budget.bytes(
source.len(),
Span {
start: 0,
end: source.len(),
},
)?;
let mut scanner = Scanner::new(source);
let mut result = Vec::new();
let mut offset = 0;
while offset < source.len() {
// Charge trivia too, before the scanner advances or a token is stored.
budget.token(Span {
start: offset,
end: offset,
})?;
let token = scanner.next().expect("source remains");
offset = token.span.end;
let class = match token.class {
RawClass::Word => Class::Word,
RawClass::Number => Class::Number,
RawClass::String => Class::String,
RawClass::Punctuation => Class::Punctuation,
RawClass::Whitespace | RawClass::Comment => continue,
RawClass::Error => return Err(error(token.span, "Invalid source token.")),
};
result.push(Token {
class,
span: token.span,
});
}
Ok(result)
}
@@ -0,0 +1,939 @@
//! Staged typed resolution. The caller owns parsing, ID stabilization and the
//! single complete-Project semantic admission after merging these additions.
use super::declarations::{self as d, Declaration, DeclarationKind};
use super::*;
use crate::analysis as a;
use crate::analysis_numeric::Dimension;
use crate::{Element, ElementBody, Id, Kind, Layer, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
/// Caller-assigned file key; spans are original offsets within that file.
pub(super) struct Unit {
pub file: usize,
pub declarations: Vec<Declaration>,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Failure {
/// None denotes an invalid ordinary skeleton, before typed resolution.
pub file: Option<usize>,
pub error: Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Category {
Project,
Element(Kind, Layer),
Relation,
Diagram,
Node,
Artifact,
Trace,
QuantityKind,
Unit,
Calculation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Target {
Global(Id),
Parameter {
calculation: Id,
parameter: a::ParameterId,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Field {
Algebra,
Kind,
Input(a::ParameterId),
Result,
Body,
Subject,
Type,
Base,
DisplayUnit,
DocumentedConstraint,
Predicate,
Override(Id),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Role {
Reference,
ParameterDeclaration,
ParameterUse,
CallTarget,
ArgumentLabel,
OverrideTarget,
}
/// Stable structural edges, independent of alias spelling and argument order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Child {
Item(usize),
Operand,
Left,
Right,
Condition,
WhenTrue,
WhenFalse,
Argument(a::ParameterId),
Values,
Initial,
Base,
Value,
}
/// An arena index is only local to this lowering result. Follow parent edges
/// to obtain the owner/field/path identity for S3; never persist the index.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct ExpressionSite {
pub file: usize,
pub owner: Id,
pub field: Field,
pub parent: Option<(usize, Child)>,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Occurrence {
pub file: usize,
pub owner: Id,
pub field: Field,
pub expression: Option<usize>,
pub role: Role,
pub target: Target,
/// Alias/name token only, excluding @/$ and surrounding trivia.
pub span: Span,
}
#[derive(Debug)]
pub(super) struct Lowered {
pub definitions: BTreeMap<Id, a::Definition>,
pub elements: BTreeMap<Id, Element>,
pub occurrences: Vec<Occurrence>,
pub expressions: Vec<ExpressionSite>,
}
#[derive(Clone, Copy)]
struct Symbol {
id: Id,
category: Category,
}
#[derive(Default)]
struct Tables {
aliases: BTreeMap<String, Symbol>,
ids: BTreeSet<Id>,
parameters: BTreeMap<Id, BTreeMap<String, a::ParameterId>>,
entries: usize,
}
fn category(kind: DeclarationKind) -> Category {
match kind {
DeclarationKind::QuantityKind => Category::QuantityKind,
DeclarationKind::Unit => Category::Unit,
DeclarationKind::Calculation => Category::Calculation,
DeclarationKind::Property(layer) => Category::Element(Kind::EngineeringProperty, layer),
DeclarationKind::Check(layer) => Category::Element(Kind::AnalysisCheck, layer),
DeclarationKind::Case(layer) => Category::Element(Kind::AnalysisCase, layer),
}
}
fn identity(identity: &d::IdentitySyntax, span: Span) -> Result<Id, Error> {
identity.value.ok_or_else(|| {
error(
span,
"Missing ID requires source stabilization before lowering.",
)
})
}
impl Tables {
fn reserve(&mut self, span: Span, budget: &Budget) -> Result<(), Error> {
budget.lowering_entries(self.entries + 1, span)?;
self.entries += 1;
Ok(())
}
fn insert(
&mut self,
id: Id,
alias: &str,
category: Category,
span: Span,
budget: &Budget,
) -> Result<(), Error> {
// A phase allocation bound, not a second syntax charge. Ordinary
// records, typed headers and scoped parameters share this bound.
self.reserve(span, budget)?;
if self.ids.contains(&id) {
return Err(error(span, "Duplicate global identity."));
}
if self.aliases.contains_key(alias) {
return Err(error(span, format!("Duplicate global alias '{alias}'.")));
}
self.ids.insert(id);
self.aliases
.insert(alias.to_owned(), Symbol { id, category });
Ok(())
}
fn ordinary(&mut self, project: &Project, budget: &Budget) -> Result<(), Error> {
let at = Span::default();
self.insert(project.id, &project.alias, Category::Project, at, budget)?;
for value in project.definitions.values() {
let category = match &value.body {
a::DefinitionBody::QuantityKind(_) => Category::QuantityKind,
a::DefinitionBody::Unit(_) => Category::Unit,
a::DefinitionBody::Calculation(_) => Category::Calculation,
};
self.insert(value.id, &value.alias, category, at, budget)?;
if let a::DefinitionBody::Calculation(calculation) = &value.body {
let mut names = BTreeMap::new();
let mut ids = BTreeSet::new();
for parameter in &calculation.parameters {
self.reserve(at, budget)?;
if names.contains_key(&parameter.name) || !ids.insert(parameter.id) {
return Err(error(
at,
"Duplicate parameter name or identity in ordinary catalog.",
));
}
names.insert(parameter.name.clone(), parameter.id);
}
self.parameters.insert(value.id, names);
}
}
for value in project.elements.values() {
self.insert(
value.id,
&value.alias,
Category::Element(value.kind(), value.layer()),
at,
budget,
)?;
}
for value in project.relations.values() {
self.insert(value.id, &value.alias, Category::Relation, at, budget)?;
}
for value in project.diagrams.values() {
self.insert(value.id, &value.alias, Category::Diagram, at, budget)?;
for node in value.nodes.values() {
self.insert(node.id, &node.alias, Category::Node, at, budget)?;
}
}
for value in project.artifact_links.values() {
self.insert(value.id, &value.alias, Category::Artifact, at, budget)?;
}
for value in project.traces.values() {
self.insert(value.id, &value.alias, Category::Trace, at, budget)?;
}
Ok(())
}
fn typed(&mut self, declaration: &Declaration, budget: &Budget) -> Result<(), Error> {
let header = &declaration.header;
let id = identity(&header.identity, header.alias.span)?;
self.insert(
id,
&header.alias.name,
category(header.kind),
header.alias.span,
budget,
)?;
if let d::Body::Calculation { parameters, .. } = &declaration.body {
let mut names = BTreeMap::new();
let mut ids = BTreeSet::new();
for parameter in parameters {
self.reserve(parameter.span, budget)?;
let id = a::ParameterId(identity(&parameter.identity, parameter.name.span)?);
if names.contains_key(&parameter.name.name) || !ids.insert(id) {
return Err(error(
parameter.name.span,
"Duplicate parameter name or identity.",
));
}
names.insert(parameter.name.name.clone(), id);
}
self.parameters.insert(id, names);
}
Ok(())
}
}
/// Inputs must come from the same live Budget and stabilization pass. No scan,
/// exact conversion or reference charge occurs here. The ordinary skeleton is
/// not semantically admitted: its typed forward references may need additions.
pub(super) fn lower(
ordinary: &Project,
units: &[Unit],
budget: &Budget,
) -> Result<Lowered, Failure> {
budget
.active()
.map_err(|error| Failure { file: None, error })?;
let mut tables = Tables::default();
tables
.ordinary(ordinary, budget)
.map_err(|error| Failure { file: None, error })?;
let mut files = BTreeSet::new();
for unit in units {
budget
.lowering_entries(files.len() + 1, Span::default())
.map_err(|error| Failure {
file: Some(unit.file),
error,
})?;
if !files.insert(unit.file) {
return Err(Failure {
file: Some(unit.file),
error: error(Span::default(), "Duplicate source file key."),
});
}
for declaration in &unit.declarations {
tables.typed(declaration, budget).map_err(|error| Failure {
file: Some(unit.file),
error,
})?;
}
}
let mut output = Lowered {
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
occurrences: Vec::new(),
expressions: Vec::new(),
};
for unit in units {
for declaration in &unit.declarations {
let owner = declaration
.header
.identity
.value
.expect("collected stable identity");
let mut resolver = Resolver {
tables: &tables,
output: &mut output,
budget,
file: unit.file,
owner,
};
resolver.declaration(declaration).map_err(|error| Failure {
file: Some(unit.file),
error,
})?;
}
}
Ok(output)
}
struct Resolver<'a> {
tables: &'a Tables,
output: &'a mut Lowered,
budget: &'a Budget,
file: usize,
owner: Id,
}
impl Resolver<'_> {
fn occurrence(
&mut self,
reference: &Reference,
field: Field,
expression: Option<usize>,
role: Role,
target: Target,
) -> Result<(), Error> {
self.budget
.lowering_occurrences(self.output.occurrences.len() + 1, reference.span)?;
self.output.occurrences.push(Occurrence {
file: self.file,
owner: self.owner,
field,
expression,
role,
target,
span: reference.span,
});
Ok(())
}
fn global(
&mut self,
reference: &Reference,
field: Field,
expression: Option<usize>,
role: Role,
expected: impl FnOnce(Category) -> bool,
) -> Result<Symbol, Error> {
let symbol = *self.tables.aliases.get(&reference.name).ok_or_else(|| {
error(
reference.span,
format!("Unknown global reference '@{}'.", reference.name),
)
})?;
if !expected(symbol.category) {
return Err(error(
reference.span,
format!(
"Wrong reference domain for '@{}': {:?}.",
reference.name, symbol.category
),
));
}
self.occurrence(
reference,
field,
expression,
role,
Target::Global(symbol.id),
)?;
Ok(symbol)
}
fn definition_ref(
&mut self,
reference: &Reference,
field: Field,
expression: Option<usize>,
expected: Category,
) -> Result<a::DefinitionRef, Error> {
self.global(reference, field, expression, Role::Reference, |category| {
category == expected
})
.map(|symbol| a::DefinitionRef::Local(symbol.id))
}
fn value_type(
&mut self,
value: &d::TypeSyntax,
field: Field,
) -> Result<a::AnalysisType, Error> {
let scalar =
match &value.scalar.kind {
d::ScalarKind::Boolean => a::ScalarType::Boolean,
d::ScalarKind::Number => a::ScalarType::Number,
d::ScalarKind::Quantity(reference) => a::ScalarType::Quantity(
self.definition_ref(reference, field, None, Category::QuantityKind)?,
),
};
Ok(if value.list {
a::AnalysisType::List(scalar)
} else {
a::AnalysisType::Scalar(scalar)
})
}
fn subject(&mut self, reference: &Reference) -> Result<a::AnalysisSubject, Error> {
let symbol = self.global(
reference,
Field::Subject,
None,
Role::Reference,
|category| matches!(category, Category::Element(..) | Category::Relation),
)?;
Ok(if symbol.category == Category::Relation {
a::AnalysisSubject::Relation(symbol.id)
} else {
a::AnalysisSubject::Element(symbol.id)
})
}
fn optional_subject(
&mut self,
subject: &Option<d::Field<Option<Reference>>>,
) -> Result<Option<a::AnalysisSubject>, Error> {
subject
.as_ref()
.and_then(|field| field.value.as_ref())
.map(|reference| self.subject(reference))
.transpose()
}
fn declaration(&mut self, declaration: &Declaration) -> Result<(), Error> {
let header = &declaration.header;
let definition = match &declaration.body {
d::Body::QuantityKind { dimension, algebra } => {
Some(a::DefinitionBody::QuantityKind(a::QuantityKindDefinition {
dimension: Dimension::new(dimension.value.exponents)
.map_err(|cause| error(dimension.value_span, cause.to_string()))?,
algebra: match &algebra.value {
d::AlgebraSyntax::Linear => a::QuantityAlgebra::Linear,
d::AlgebraSyntax::Affine(reference) => a::QuantityAlgebra::Affine {
difference: self.definition_ref(
reference,
Field::Algebra,
None,
Category::QuantityKind,
)?,
},
},
}))
}
d::Body::Unit {
kind,
scale,
offset,
symbol,
} => Some(a::DefinitionBody::Unit(a::UnitDefinition {
kind: self.definition_ref(
&kind.value,
Field::Kind,
None,
Category::QuantityKind,
)?,
scale: scale.value.value.clone(),
offset: offset.value.value.clone(),
symbol: symbol.value.clone(),
})),
d::Body::Calculation {
parameters,
result,
body,
} => {
let mut lowered = Vec::new();
for parameter in parameters {
self.budget
.lowering_entries(lowered.len() + 1, parameter.span)?;
let id = self.tables.parameters[&self.owner][&parameter.name.name];
self.occurrence(
&parameter.name,
Field::Input(id),
None,
Role::ParameterDeclaration,
Target::Parameter {
calculation: self.owner,
parameter: id,
},
)?;
lowered.push(a::Parameter {
id,
name: parameter.name.name.clone(),
value_type: self.value_type(&parameter.value_type, Field::Input(id))?,
});
}
Some(a::DefinitionBody::Calculation(a::CalculationDefinition {
parameters: lowered,
result: self.value_type(&result.value, Field::Result)?,
body: self.binding(&body.value, Field::Body, Some(self.owner))?,
}))
}
_ => None,
};
let description = declaration
.description
.as_ref()
.map_or_else(String::new, |field| field.value.clone());
if let Some(body) = definition {
self.output.definitions.insert(
self.owner,
a::Definition {
id: self.owner,
alias: header.alias.name.clone(),
name: header.name.clone(),
description,
body,
},
);
return Ok(());
}
let (layer, body) = match (&declaration.body, header.kind) {
(
d::Body::Property {
subject,
value_type,
base,
display_unit,
},
DeclarationKind::Property(layer),
) => (
layer,
SharedBody::EngineeringProperty {
subject: self.subject(&subject.value)?,
value_type: self.value_type(&value_type.value, Field::Type)?,
base: self.binding(&base.value, Field::Base, None)?,
display_unit: display_unit
.as_ref()
.and_then(|field| field.value.as_ref())
.map(|reference| {
self.definition_ref(reference, Field::DisplayUnit, None, Category::Unit)
})
.transpose()?,
},
),
(
d::Body::Check {
subject,
documented_constraint,
predicate,
},
DeclarationKind::Check(layer),
) => (
layer,
SharedBody::AnalysisCheck {
subject: self.optional_subject(subject)?,
documented_constraint: documented_constraint
.as_ref()
.and_then(|field| field.value.as_ref())
.map(|reference| {
self.global(
reference,
Field::DocumentedConstraint,
None,
Role::Reference,
|category| {
matches!(category, Category::Element(Kind::Constraint, _))
},
)
.map(|symbol| symbol.id)
})
.transpose()?,
predicate: self.binding(&predicate.value, Field::Predicate, None)?,
},
),
(
d::Body::Case {
subject, overrides, ..
},
DeclarationKind::Case(layer),
) => {
let subject = self.optional_subject(subject)?;
let mut lowered = BTreeMap::new();
for entry in &overrides.value {
let property = self
.global(
&entry.target,
Field::Base,
None,
Role::OverrideTarget,
|category| {
matches!(category, Category::Element(Kind::EngineeringProperty, _))
},
)?
.id;
// Replace the placeholder field after resolution so the key
// and its binding share their stable override identity.
self.output
.occurrences
.last_mut()
.expect("override occurrence")
.field = Field::Override(property);
if lowered.contains_key(&property) {
return Err(error(
entry.target.span,
"Duplicate resolved override target.",
));
}
self.budget
.lowering_entries(lowered.len() + 1, entry.span)?;
lowered.insert(
property,
self.binding(&entry.binding, Field::Override(property), None)?,
);
}
(
layer,
SharedBody::AnalysisCase {
subject,
base: a::BaselineBase::ModelBaseline,
overrides: lowered,
},
)
}
_ => {
return Err(error(
header.kind_span,
"Typed declaration header/body mismatch.",
));
}
};
self.output.elements.insert(
self.owner,
Element {
id: self.owner,
alias: header.alias.name.clone(),
name: header.name.clone(),
description,
parent: None,
body: ElementBody::shared_in(layer, body),
},
);
Ok(())
}
fn binding(
&mut self,
binding: &Binding,
field: Field,
calculation: Option<Id>,
) -> Result<a::Binding, Error> {
match binding {
Binding::Unbound(_) => Ok(a::Binding::Unbound),
Binding::Expression(expression) => self
.expression(expression, field, calculation)
.map(a::Binding::Expression),
}
}
fn call(
&mut self,
definition: &Reference,
arguments: &[Argument],
field: Field,
site: usize,
) -> Result<Call, Error> {
let calculation = self
.global(
definition,
field,
Some(site),
Role::CallTarget,
|category| category == Category::Calculation,
)?
.id;
let tables = self.tables;
let signature = &tables.parameters[&calculation];
let mut parameters = Vec::new();
let mut seen = BTreeSet::new();
for argument in arguments {
self.budget
.lowering_entries(parameters.len() + 1, argument.parameter.span)?;
let parameter = *signature.get(&argument.parameter.name).ok_or_else(|| {
error(
argument.parameter.span,
"Unknown argument name in called calculation.",
)
})?;
if !seen.insert(parameter) {
return Err(error(
argument.parameter.span,
"Duplicate resolved argument parameter.",
));
}
self.occurrence(
&argument.parameter,
field,
Some(site),
Role::ArgumentLabel,
Target::Parameter {
calculation,
parameter,
},
)?;
parameters.push(parameter);
}
// Argument-set/type checking remains in existing semantic admission.
Ok(Call {
calculation,
parameters,
})
}
fn expression(
&mut self,
root: &Expression,
field: Field,
calculation: Option<Id>,
) -> Result<a::Expr, Error> {
enum Work<'a> {
Visit(&'a Expression, Option<(usize, Child)>),
Build(&'a Expression, usize, Option<Call>, usize),
}
let mut work = vec![Work::Visit(root, None)];
let mut values = Vec::new();
while let Some(task) = work.pop() {
match task {
Work::Visit(expression, parent) => {
self.budget
.lowering_entries(self.output.expressions.len() + 1, expression.span)?;
let site = self.output.expressions.len();
self.output.expressions.push(ExpressionSite {
file: self.file,
owner: self.owner,
field,
parent,
span: expression.span,
});
let call = if let ExpressionKind::Call {
definition,
arguments,
} = &expression.kind
{
Some(self.call(definition, arguments, field, site)?)
} else {
None
};
let child_count = match &expression.kind {
ExpressionKind::List(items) => items.len(),
ExpressionKind::Call { arguments, .. } => arguments.len(),
ExpressionKind::Unary { .. }
| ExpressionKind::Power { .. }
| ExpressionKind::AsKind { .. } => 1,
ExpressionKind::Binary { .. } | ExpressionKind::Sum { .. } => 2,
ExpressionKind::If { .. } => 3,
_ => 0,
};
self.budget
.lowering_entries(work.len() + child_count + 1, expression.span)?;
let mut children = Vec::with_capacity(child_count);
match &expression.kind {
ExpressionKind::List(items) => children.extend(
items
.iter()
.enumerate()
.map(|(index, child)| (child, Child::Item(index))),
),
ExpressionKind::Unary { operand, .. } => {
children.push((operand.as_ref(), Child::Operand))
}
ExpressionKind::Binary { left, right, .. } => children
.extend([(left.as_ref(), Child::Left), (right.as_ref(), Child::Right)]),
ExpressionKind::If {
condition,
when_true,
when_false,
} => children.extend([
(condition.as_ref(), Child::Condition),
(when_true.as_ref(), Child::WhenTrue),
(when_false.as_ref(), Child::WhenFalse),
]),
ExpressionKind::Call { arguments, .. } => children.extend(
arguments
.iter()
.zip(&call.as_ref().expect("call signature").parameters)
.map(|(argument, id)| (&argument.value, Child::Argument(*id))),
),
ExpressionKind::Sum { values, initial } => children.extend([
(values.as_ref(), Child::Values),
(initial.as_ref(), Child::Initial),
]),
ExpressionKind::Power { base, .. } => {
children.push((base.as_ref(), Child::Base))
}
ExpressionKind::AsKind { value, .. } => {
children.push((value.as_ref(), Child::Value))
}
_ => {}
}
work.push(Work::Build(expression, site, call, children.len()));
work.extend(
children
.into_iter()
.rev()
.map(|(child, edge)| Work::Visit(child, Some((site, edge)))),
);
}
Work::Build(expression, site, call, count) => {
let start = values.len() - count;
let mut children = values.split_off(start).into_iter();
let next = |children: &mut std::vec::IntoIter<a::Expr>| {
Box::new(children.next().expect("visited expression child"))
};
use ExpressionKind as E;
let value = match &expression.kind {
E::Boolean(value) => a::Expr::Boolean(*value),
E::Number(value) => a::Expr::Number(value.clone()),
E::Quantity { magnitude, unit } => a::Expr::Quantity {
magnitude: magnitude.clone(),
unit: self.definition_ref(unit, field, Some(site), Category::Unit)?,
},
E::Property(reference) => a::Expr::Property(
self.global(
reference,
field,
Some(site),
Role::Reference,
|category| {
matches!(
category,
Category::Element(Kind::EngineeringProperty, _)
)
},
)?
.id,
),
E::Parameter(reference) => {
let calculation = calculation.ok_or_else(|| {
error(
reference.span,
"Parameter use is only valid in its calculation body.",
)
})?;
let parameter = *self.tables.parameters[&calculation]
.get(&reference.name)
.ok_or_else(|| {
error(
reference.span,
"Unknown parameter in owning calculation.",
)
})?;
self.occurrence(
reference,
field,
Some(site),
Role::ParameterUse,
Target::Parameter {
calculation,
parameter,
},
)?;
a::Expr::Parameter(parameter)
}
E::List(_) => a::Expr::List(children.collect()),
E::Unary { op, .. } => a::Expr::Unary {
op: *op,
operand: next(&mut children),
},
E::Binary { op, .. } => a::Expr::Binary {
op: *op,
left: next(&mut children),
right: next(&mut children),
},
E::If { .. } => a::Expr::If {
condition: next(&mut children),
when_true: next(&mut children),
when_false: next(&mut children),
},
E::Call { .. } => {
let call = call.expect("resolved call");
a::Expr::Call {
definition: a::DefinitionRef::Local(call.calculation),
arguments: call.parameters.into_iter().zip(children).collect(),
}
}
E::Sum { .. } => a::Expr::Sum {
values: next(&mut children),
initial: next(&mut children),
},
E::Power { exponent, .. } => a::Expr::Power {
base: next(&mut children),
exponent: *exponent,
},
E::AsKind { kind, .. } => a::Expr::AsKind {
kind: self.definition_ref(
kind,
field,
Some(site),
Category::QuantityKind,
)?,
value: next(&mut children),
},
};
self.budget
.lowering_entries(values.len() + 1, expression.span)?;
values.push(value);
}
}
}
Ok(values.pop().expect("one lowered expression"))
}
}
struct Call {
calculation: Id,
parameters: Vec<a::ParameterId>,
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,7 @@
arcadia 7;
project response_budget "Response budget" id "4210b9f2-7046-47f0-aa3b-153f626a579d" {
oa_activity respond "Respond" id "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
oa_activity receive "Receive" id "4ab9a8a7-7552-488d-9862-e14b89e7b353" {}
oa_activity dispatch "Dispatch" id "6df991ba-4c5e-4535-99e2-524ae274ee8b" {}
}
}
@@ -0,0 +1,7 @@
arcadia 7;
project response_budget "Response budget" id "c210b9f2-7046-47f0-aa3b-153f626a579d" {
sa_function respond "Respond" id "4abec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
sa_function receive "Receive" id "cab9a8a7-7552-488d-9862-e14b89e7b353" {}
sa_function dispatch "Dispatch" id "edf991ba-4c5e-4535-99e2-524ae274ee8b" {}
}
}
@@ -0,0 +1,43 @@
// Staged typed roots from the r2 example; ordinary roots are supplied separately.
// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive.
oa_property receive_time "Receive time" id "63a931c2-675a-4fce-89e5-04361cb99450" {
subject @receive; type Quantity(@duration); display_unit @millisecond;
base 750 [@millisecond];
}
oa_property dispatch_time "Dispatch time" id "c33fc772-2a60-4044-9af9-73c61c563ace" {
subject @dispatch; type Quantity(@duration); display_unit @second;
base 1 [@second];
}
oa_property total_time "Total time" id "bd5b68e8-65fc-4e35-9071-a73061316a4b" {
subject @respond; type Quantity(@duration); display_unit @second;
base @combine(a: @receive_time, b: @dispatch_time);
}
oa_property limit "Allowed duration" id "437bedbb-2cf4-44ee-a139-6563cc5086ec" {
subject @respond; type Quantity(@duration); display_unit @second;
base 2 [@second];
}
oa_check budget "Response budget" id "bac0de99-9d2f-4e53-9158-d424ecac61bf" {
subject none; documented_constraint none; predicate @total_time <= @limit;
}
oa_analysis degraded "Degraded radio" id "38036c65-16d4-449c-837b-91eb141c1d93" {
subject none; base ModelBaseline;
overrides { @receive_time = 1250 [@millisecond]; };
}
oa_analysis unmeasured "Unmeasured radio" id "99d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
subject none; base ModelBaseline;
overrides { @receive_time = unbound; };
}
calculation combine "Combine durations" id "fd7ce7ab-f789-40bc-b699-0c37f1b6902f" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
result Quantity(@duration); body $a + $b;
}
unit second "Second" id "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
kind @duration; scale 1; offset 0; symbol "s";
}
unit millisecond "Millisecond" id "39053f37-fec9-4708-9f0a-3959d223f891" {
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
}
quantity_kind duration "Duration" id "188b911c-5f8b-431c-9eea-2d41f3953767" {
dimension { time 1; }; algebra Linear;
}
@@ -0,0 +1,43 @@
// Staged typed roots from the r2 example; ordinary roots are supplied separately.
// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive.
sa_property receive_time "Receive time" id "e3a931c2-675a-4fce-89e5-04361cb99450" {
subject @receive; type Quantity(@duration); display_unit @millisecond;
base 750 [@millisecond];
}
sa_property dispatch_time "Dispatch time" id "433fc772-2a60-4044-9af9-73c61c563ace" {
subject @dispatch; type Quantity(@duration); display_unit @second;
base 1 [@second];
}
sa_property total_time "Total time" id "3d5b68e8-65fc-4e35-9071-a73061316a4b" {
subject @respond; type Quantity(@duration); display_unit @second;
base @combine(a: @receive_time, b: @dispatch_time);
}
sa_property limit "Allowed duration" id "c37bedbb-2cf4-44ee-a139-6563cc5086ec" {
subject @respond; type Quantity(@duration); display_unit @second;
base 2 [@second];
}
sa_check budget "Response budget" id "3ac0de99-9d2f-4e53-9158-d424ecac61bf" {
subject none; documented_constraint none; predicate @total_time <= @limit;
}
sa_analysis degraded "Degraded radio" id "b8036c65-16d4-449c-837b-91eb141c1d93" {
subject none; base ModelBaseline;
overrides { @receive_time = 1250 [@millisecond]; };
}
sa_analysis unmeasured "Unmeasured radio" id "19d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
subject none; base ModelBaseline;
overrides { @receive_time = unbound; };
}
calculation combine "Combine durations" id "7d7ce7ab-f789-40bc-b699-0c37f1b6902f" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
result Quantity(@duration); body $a + $b;
}
unit second "Second" id "d34f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
kind @duration; scale 1; offset 0; symbol "s";
}
unit millisecond "Millisecond" id "b9053f37-fec9-4708-9f0a-3959d223f891" {
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
}
quantity_kind duration "Duration" id "988b911c-5f8b-431c-9eea-2d41f3953767" {
dimension { time 1; }; algebra Linear;
}
@@ -0,0 +1,888 @@
use super::*;
use crate::language::ParseLimits;
use crate::language::analysis_source::{budget::Limits, cursor::Cursor};
use super::super::model_oracle as oracle;
const OA: &str = include_str!("fixtures/typed-oa.arcadia");
const SA: &str = include_str!("fixtures/typed-sa.arcadia");
const ORDINARY_OA: &str = include_str!("fixtures/ordinary-oa.arcadia");
const ORDINARY_SA: &str = include_str!("fixtures/ordinary-sa.arcadia");
fn budget() -> Budget {
Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap()
}
fn id(text: &str) -> Id {
Id::parse_str(text).unwrap()
}
fn by_alias(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn ordinary(source: &str) -> Project {
crate::language::parse(source).unwrap()
}
fn unit(source: &str, file: usize, budget: &mut Budget) -> Unit {
let tokens = lex::tokens(source, budget).unwrap();
let mut cursor = Cursor::new(source, &tokens, budget);
let mut declarations = Vec::new();
while cursor.cursor < tokens.len() {
declarations.push(d::parse(&mut cursor, 8, None).unwrap());
}
Unit { file, declarations }
}
fn merge(mut ordinary: Project, lowered: Lowered) -> Project {
ordinary.definitions.extend(lowered.definitions);
ordinary.elements.extend(lowered.elements);
ordinary
}
fn admit(project: &Project) {
a::admit(project, a::AdmissionLimits::REFERENCE).unwrap();
}
fn failure(source: &str, ordinary: &Project) -> Failure {
let mut budget = budget();
let unit = unit(source, 7, &mut budget);
lower(ordinary, &[unit], &budget).unwrap_err()
}
#[test]
fn staged_oa_and_sa_lowering_match_complete_independent_17_uuid_oracles() {
for (layer, ordinary_source, typed_source) in
[(Layer::Oa, ORDINARY_OA, OA), (Layer::Sa, ORDINARY_SA, SA)]
{
let expected = oracle::project(layer);
assert_eq!(expected.name, "Response budget");
assert_eq!(expected.identities().len(), 15); // two additional scoped IDs
for (alias, name) in oracle::NAMES {
let object = by_alias(&expected, alias);
let actual_name = expected
.definitions
.get(&object)
.map(|value| value.name.as_str())
.or_else(|| {
expected
.elements
.get(&object)
.map(|value| value.name.as_str())
})
.unwrap();
assert_eq!(actual_name, name);
assert_ne!(actual_name, alias);
}
let mut budget = budget();
let units = [unit(typed_source, 17, &mut budget)];
let usage = budget.usage;
let numeric = budget.numeric_units();
let skeleton = ordinary(ordinary_source);
let first = lower(&skeleton, &units, &budget).unwrap();
let second = lower(&skeleton, &units, &budget).unwrap();
assert_eq!(first.occurrences, second.occurrences);
assert_eq!(first.expressions, second.expressions);
assert_eq!(budget.usage, usage);
assert_eq!(budget.numeric_units(), numeric);
assert_eq!(
first
.occurrences
.iter()
.filter(|occurrence| occurrence.role != Role::ParameterDeclaration)
.count(),
usage.references
);
let actual = merge(skeleton, first);
assert_eq!(actual, expected);
admit(&actual); // the complete model's one semantic admission
}
assert!(crate::language::parse(&ORDINARY_OA.replacen("arcadia 7", "arcadia 8", 1)).is_err());
}
#[test]
fn forward_cross_unit_resolution_keeps_original_offsets_and_shared_work() {
let source = OA.replace('\n', "\r\n");
let split = source.find("calculation combine").unwrap();
let sources = [&source[..split], &source[split..]];
let mut budget = budget();
let mut units = [
unit(sources[0], 4, &mut budget),
unit(sources[1], 9, &mut budget),
];
let usage = budget.usage;
assert_eq!(usage.bytes, source.len());
let lowered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
units.reverse();
// This second order uses the same bounded lowerer; output models must not
// depend on declaration/file traversal order. It is not an include parser.
let reordered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
assert_eq!(
merge(ordinary(ORDINARY_OA), reordered),
oracle::project(Layer::Oa)
);
for occurrence in &lowered.occurrences {
let source = if occurrence.file == 4 {
sources[0]
} else {
assert_eq!(occurrence.file, 9);
sources[1]
};
let text = &source[occurrence.span.start..occurrence.span.end];
assert!(crate::valid_alias(text));
assert!(!text.starts_with('@') && !text.starts_with('$'));
}
assert_eq!(budget.usage, usage);
assert_eq!(
merge(ordinary(ORDINARY_OA), lowered),
oracle::project(Layer::Oa)
);
}
fn number(value: i64) -> a::Expr {
a::Expr::Number(crate::analysis_numeric::Exact::from_integer(value))
}
fn binary(op: a::BinaryOp, left: a::Expr, right: a::Expr) -> a::Expr {
a::Expr::Binary {
op,
left: Box::new(left),
right: Box::new(right),
}
}
#[test]
fn all_thirteen_expression_variants_lower_to_real_typed_values_and_admit() {
let mut project = oracle::project(Layer::Oa);
let duration = by_alias(&project, "duration");
let second = by_alias(&project, "second");
let property = by_alias(&project, "receive_time");
let owner = by_alias(&project, "total_time");
let calculation = by_alias(&project, "combine");
let parameter = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let b = a::ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
let quantity =
a::AnalysisType::Scalar(a::ScalarType::Quantity(a::DefinitionRef::Local(duration)));
let numeric = a::AnalysisType::Scalar(a::ScalarType::Number);
let boolean = a::AnalysisType::Scalar(a::ScalarType::Boolean);
let cases = [
("true", a::Expr::Boolean(true), boolean.clone()),
(
"ratio(3, 2)",
a::Expr::Number(
Exact::from_wire(
&crate::analysis_numeric::ExactWire {
numerator: "3".into(),
denominator: "2".into(),
},
&mut crate::analysis_numeric::WorkMeter::new(
crate::analysis_numeric::WorkLimits::REFERENCE,
),
)
.unwrap(),
),
numeric.clone(),
),
(
"2 [@second]",
a::Expr::Quantity {
magnitude: Exact::from_integer(2),
unit: a::DefinitionRef::Local(second),
},
quantity.clone(),
),
(
"@receive_time",
a::Expr::Property(property),
quantity.clone(),
),
("$a", a::Expr::Parameter(parameter), quantity.clone()),
(
"[1, 2]",
a::Expr::List(vec![number(1), number(2)]),
a::AnalysisType::List(a::ScalarType::Number),
),
(
"abs(-2)",
a::Expr::Unary {
op: a::UnaryOp::Absolute,
operand: Box::new(a::Expr::Unary {
op: a::UnaryOp::Negate,
operand: Box::new(number(2)),
}),
},
numeric.clone(),
),
(
"1 + 2",
binary(a::BinaryOp::Add, number(1), number(2)),
numeric.clone(),
),
(
"if(true, 1, 2)",
a::Expr::If {
condition: Box::new(a::Expr::Boolean(true)),
when_true: Box::new(number(1)),
when_false: Box::new(number(2)),
},
numeric.clone(),
),
(
"@combine(b: 2 [@second], a: 1 [@second])",
a::Expr::Call {
definition: a::DefinitionRef::Local(calculation),
arguments: BTreeMap::from([
(
parameter,
a::Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: a::DefinitionRef::Local(second),
},
),
(
b,
a::Expr::Quantity {
magnitude: Exact::from_integer(2),
unit: a::DefinitionRef::Local(second),
},
),
]),
},
quantity.clone(),
),
(
"sum([1, 2], 0)",
a::Expr::Sum {
values: Box::new(a::Expr::List(vec![number(1), number(2)])),
initial: Box::new(number(0)),
},
numeric.clone(),
),
(
"power(2, -2)",
a::Expr::Power {
base: Box::new(number(2)),
exponent: -2,
},
numeric.clone(),
),
(
"as_kind(@duration, power(1 [@second], 2) / 1 [@second])",
a::Expr::AsKind {
kind: a::DefinitionRef::Local(duration),
value: Box::new(binary(
a::BinaryOp::Divide,
a::Expr::Power {
base: Box::new(a::Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: a::DefinitionRef::Local(second),
}),
exponent: 2,
},
a::Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: a::DefinitionRef::Local(second),
},
)),
},
quantity.clone(),
),
];
// Remove the original check's dependent quantity comparison: each case
// installs a different declared result type on total_time below.
project.elements.remove(&by_alias(&project, "budget"));
for (source, expected, result_type) in cases {
let mut budget = budget();
let Binding::Expression(syntax) = super::super::parse(source, &mut budget).unwrap() else {
panic!("expression")
};
let mut tables = Tables::default();
tables.ordinary(&project, &budget).unwrap();
let mut output = Lowered {
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
occurrences: Vec::new(),
expressions: Vec::new(),
};
let is_parameter = source == "$a";
let expression_owner = if is_parameter { calculation } else { owner };
let actual = Resolver {
tables: &tables,
output: &mut output,
budget: &budget,
file: 3,
owner: expression_owner,
}
.expression(
&syntax,
if is_parameter {
Field::Body
} else {
Field::Base
},
is_parameter.then_some(calculation),
)
.unwrap();
assert_eq!(actual, expected, "{source}");
assert_eq!(output.expressions[0].span, syntax.span);
assert!(output.expressions[0].parent.is_none());
let mut candidate = project.clone();
if is_parameter {
let a::DefinitionBody::Calculation(definition) =
&mut candidate.definitions.get_mut(&calculation).unwrap().body
else {
panic!("calculation")
};
definition.body = a::Binding::Expression(actual);
} else {
let SharedBody::EngineeringProperty {
value_type,
display_unit,
base,
..
} = candidate
.elements
.get_mut(&owner)
.unwrap()
.body
.shared_mut()
.unwrap()
else {
panic!("property")
};
*value_type = result_type;
*display_unit = None;
*base = a::Binding::Expression(actual);
}
admit(&candidate);
}
}
#[test]
fn call_sites_and_parameter_scopes_preserve_distinct_original_occurrences() {
let source = OA.replace("base @combine(a: @receive_time, b: @dispatch_time);", "base @combine(a: @receive_time, // first\n b: @dispatch_time) + @combine(// second\n b: @dispatch_time, a: @receive_time);") + r#"
calculation other "Other" id "00000000-0000-0000-0000-000000000020" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Number;
result Number; body $a;
}
quantity_kind collision "Global ID equals a scoped ID" id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" { dimension {}; algebra Linear; }
calculation caller "Caller" id "00000000-0000-0000-0000-000000000021" {
input a id "00000000-0000-0000-0000-000000000022" : Number;
result Number; body @other(a: $a) + @other(a: 3);
}
"#;
let mut budget = budget();
let units = [unit(&source, 2, &mut budget)];
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b");
let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f");
let labels: Vec<_> = output
.occurrences
.iter()
.filter(|value| value.owner == total && value.role == Role::ArgumentLabel)
.collect();
assert_eq!(labels.len(), 4);
assert_eq!(
labels
.iter()
.map(|value| value.span.start)
.collect::<BTreeSet<_>>()
.len(),
4
);
let sites: BTreeSet<_> = labels
.iter()
.map(|value| value.expression.unwrap())
.collect();
assert_eq!(sites.len(), 2);
let paths: Vec<_> = sites
.iter()
.map(|site| output.expressions[*site].parent.unwrap().1)
.collect();
assert_eq!(paths, [Child::Left, Child::Right]);
for label in labels {
assert_eq!(label.field, Field::Base);
assert!(
matches!(label.target, Target::Parameter { calculation, .. } if calculation == combine)
);
assert!(matches!(
&source[label.span.start..label.span.end],
"a" | "b"
));
}
let reused = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let scopes: BTreeSet<_> = output
.occurrences
.iter()
.filter_map(|occurrence| match occurrence.target {
Target::Parameter {
calculation,
parameter,
} if parameter == reused => Some(calculation),
_ => None,
})
.collect();
assert_eq!(
scopes,
BTreeSet::from([combine, id("00000000-0000-0000-0000-000000000020")])
);
let caller = id("00000000-0000-0000-0000-000000000021");
let use_in_caller = output
.occurrences
.iter()
.find(|value| value.owner == caller && value.role == Role::ParameterUse)
.unwrap();
assert_eq!(
use_in_caller.target,
Target::Parameter {
calculation: caller,
parameter: a::ParameterId(id("00000000-0000-0000-0000-000000000022"))
}
);
for site in &output.expressions {
assert_eq!(site.file, 2);
assert!(source.get(site.span.start..site.span.end).is_some());
}
admit(&merge(ordinary(ORDINARY_OA), output));
}
#[test]
fn missing_ids_require_stabilization_without_uuid_or_budget_churn() {
for source in [
r#"calculation pending "Pending" { result Number; body unbound; }"#,
r#"calculation pending "Pending" id "00000000-0000-0000-0000-000000000040" { input value : Number; result Number; body $value; }"#,
] {
let mut budget = budget();
let units = [unit(source, 3, &mut budget)];
let usage = budget.usage;
let skeleton = ordinary(ORDINARY_OA);
let first = lower(&skeleton, &units, &budget).unwrap_err();
assert_eq!(first, lower(&skeleton, &units, &budget).unwrap_err());
assert!(first.error.message.contains("stabilization"));
assert_eq!(budget.usage, usage);
let declaration = &units[0].declarations[0];
if declaration.header.identity.value.is_none() {
assert!(declaration.header.identity.insertion.is_some());
} else {
let d::Body::Calculation { parameters, .. } = &declaration.body else {
panic!("calculation")
};
assert!(parameters[0].identity.value.is_none());
assert!(parameters[0].identity.insertion.is_some());
}
}
}
#[test]
fn duplicate_global_aliases_and_ids_fail_before_body_resolution() {
let ordinary = ordinary(ORDINARY_OA);
for (source, message) in [
(
OA.replace("quantity_kind duration", "quantity_kind respond"),
"Duplicate global alias",
),
(
OA.replace(
"188b911c-5f8b-431c-9eea-2d41f3953767",
"cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59",
),
"Duplicate global identity",
),
(format!("{OA}\n{OA}"), "Duplicate global"),
] {
let failed = failure(&source, &ordinary);
assert_eq!(failed.file, Some(7));
assert!(failed.error.message.contains(message), "{:?}", failed);
assert!(!failed.error.message.contains("Unknown"));
}
let mut budget = budget();
let units = [unit(OA, 3, &mut budget), unit(OA, 5, &mut budget)];
let failed = lower(&ordinary, &units, &budget).unwrap_err();
assert_eq!(failed.file, Some(5));
assert!(failed.error.message.contains("Duplicate global identity"));
}
#[test]
fn typed_reference_domains_unknown_names_and_parameter_scope_fail_at_tokens() {
let skeleton = ordinary(ORDINARY_OA);
for (old, new, token, message) in [
(
"subject @receive",
"subject @second",
"second",
"Wrong reference domain",
),
(
"Quantity(@duration)",
"Quantity(@second)",
"second",
"Wrong reference domain",
),
(
"display_unit @second",
"display_unit @duration",
"duration",
"Wrong reference domain",
),
(
"750 [@millisecond]",
"750 [@duration]",
"duration",
"Wrong reference domain",
),
(
"base 1 [@second]",
"base @respond",
"respond",
"Wrong reference domain",
),
(
"@combine(a:",
"@second(a:",
"second",
"Wrong reference domain",
),
(
"@combine(a:",
"@combine(unknown:",
"unknown",
"Unknown argument",
),
(
"body $a + $b",
"body $outside + $b",
"outside",
"Unknown parameter",
),
(
"base 1 [@second]",
"base $a",
"a",
"only valid in its calculation",
),
(
"subject @receive",
"subject @missing",
"missing",
"Unknown global",
),
(
"documented_constraint none",
"documented_constraint @respond",
"respond",
"Wrong reference domain",
),
(
"@receive_time = unbound",
"@second = unbound",
"second",
"Wrong reference domain",
),
] {
let source = OA.replace(old, new);
assert_ne!(source, OA, "mutation {old}");
let failed = failure(&source, &skeleton);
assert!(
failed.error.message.contains(message),
"{new}: {:?}",
failed
);
assert_eq!(
&source[failed.error.span.start..failed.error.span.end],
token
);
}
}
#[test]
fn ordinary_non_element_categories_are_collected_and_never_property_fallbacks() {
let mut skeleton = ordinary(ORDINARY_OA);
let respond = by_alias(&skeleton, "respond");
let receive = by_alias(&skeleton, "receive");
let relation = Id::from_u128(100);
skeleton.relations.insert(
relation,
crate::Relation {
id: relation,
alias: "link".into(),
name: "Link".into(),
description: String::new(),
body: crate::RelationBody::new(
crate::RelationKind::FunctionalExchange,
respond,
receive,
),
},
);
let diagram = skeleton.ensure_diagram(Layer::Oa);
skeleton.diagrams.get_mut(&diagram).unwrap().alias = "view".into();
let node = skeleton.place(diagram, respond, crate::Position { x: 0., y: 0. });
skeleton
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.alias = "placement".into();
let artifact = Id::from_u128(101);
skeleton.artifact_links.insert(
artifact,
crate::ArtifactLink {
id: artifact,
alias: "document".into(),
name: "Document".into(),
description: String::new(),
owner: respond,
relation: crate::ArtifactRelation::References,
uri: "urn:test".into(),
revision: None,
fragment: None,
},
);
let trace = Id::from_u128(102);
skeleton.traces.insert(
trace,
crate::Trace {
id: trace,
alias: "mapping".into(),
name: "Mapping".into(),
description: String::new(),
oa: respond,
sa: receive,
kind: crate::TraceKind::Manual,
baseline: None,
},
);
// Resolution classification is independent of whole-model admission; the
// synthetic trace is deliberately not asserted to be a valid OA/SA trace.
let budget = budget();
let mut tables = Tables::default();
tables.ordinary(&skeleton, &budget).unwrap();
for (alias, category) in [
("response_budget", Category::Project),
("link", Category::Relation),
("view", Category::Diagram),
("placement", Category::Node),
("document", Category::Artifact),
("mapping", Category::Trace),
] {
assert_eq!(tables.aliases[alias].category, category);
let failed = failure(
&OA.replace("base 1 [@second]", &format!("base @{alias}")),
&skeleton,
);
assert!(
failed.error.message.contains("Wrong reference domain"),
"{:?}",
failed
);
}
let source = OA.replace("subject @receive", "subject @link");
let mut budget = self::budget();
let units = [unit(&source, 1, &mut budget)];
let output = lower(&skeleton, &units, &budget).unwrap();
let property = output
.elements
.values()
.find(|value| value.alias == "receive_time")
.unwrap();
assert!(
matches!(property.body.shared(), Some(SharedBody::EngineeringProperty { subject: a::AnalysisSubject::Relation(value), .. }) if *value == relation)
);
}
#[test]
fn structural_lowering_defers_type_purity_layer_and_argument_sets_to_admission() {
for (old, new) in [
("base 750 [@millisecond]", "base true"),
(
"base 750 [@millisecond]",
"base as_kind(@duration, 1 [@second])",
),
("body $a + $b", "body @receive_time"),
("a: @receive_time, b: @dispatch_time", "a: @receive_time"),
("oa_property receive_time", "sa_property receive_time"),
("base 1 [@second]", "base @dispatch_time"),
] {
let source = OA.replace(old, new);
let mut budget = budget();
let units = [unit(&source, 1, &mut budget)];
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
let project = merge(ordinary(ORDINARY_OA), output);
assert!(
a::admit(&project, a::AdmissionLimits::REFERENCE).is_err(),
"{new}"
);
}
}
#[test]
fn lowering_phase_bounds_count_ordinary_globals_and_scoped_parameters_together() {
let ordinary = oracle::project(Layer::Oa);
for (maximum, passes) in [(0, false), (16, false), (17, true)] {
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.entries = maximum;
let budget = Budget::new(limits).unwrap();
let result = lower(&ordinary, &[], &budget);
assert_eq!(result.is_ok(), passes, "entry bound {maximum}");
if let Err(failure) = result {
assert!(failure.error.message.contains("lowering entry limit"));
assert_eq!(failure.file, None);
}
assert_eq!(budget.usage, budget::Usage::default());
}
}
#[test]
fn affine_kinds_optional_fields_list_signatures_and_unused_unbound_definitions_survive() {
let source = r#"
unit celsius "Celsius" id "00000000-0000-0000-0000-000000000061" {
kind @point; scale 1; offset ratio(27315, 100); symbol "°C";
}
quantity_kind point "Temperature point" id "00000000-0000-0000-0000-000000000062" {
dimension { temperature 1; }; algebra Affine(@interval);
}
quantity_kind interval "Temperature interval" id "00000000-0000-0000-0000-000000000063" {
dimension { temperature 1; }; algebra Linear;
}
calculation unused "Unused list calculation" id "00000000-0000-0000-0000-000000000064" {
input xs id "00000000-0000-0000-0000-000000000065" : List(Quantity(@interval));
result List(Quantity(@interval)); body unbound; description "保留";
}
calculation empty "Empty calculation" id "00000000-0000-0000-0000-000000000066" {
result Boolean; body unbound;
}
oa_property ambient "Ambient temperature" id "00000000-0000-0000-0000-000000000067" {
subject @respond; type Quantity(@point); display_unit none; base 0 [@celsius]; parent none;
}
oa_check documented "Documented check" id "00000000-0000-0000-0000-000000000068" {
subject @respond; documented_constraint @requirement; predicate unbound;
}
oa_analysis changed "Changed temperature" id "00000000-0000-0000-0000-000000000069" {
subject @respond; base ModelBaseline; overrides { @ambient = unbound; };
}
"#;
let mut skeleton = ordinary(ORDINARY_OA);
let requirement = Id::from_u128(70);
skeleton.elements.insert(
requirement,
Element {
id: requirement,
alias: "requirement".into(),
name: "Requirement".into(),
description: String::new(),
parent: None,
body: ElementBody::shared_in(
Layer::Oa,
SharedBody::Constraint {
expression: String::new(),
constrained: Vec::new(),
},
),
},
);
let mut budget = budget();
let units = [unit(source, 8, &mut budget)];
let output = lower(&skeleton, &units, &budget).unwrap();
assert_eq!(output.definitions.len(), 5);
let point = id("00000000-0000-0000-0000-000000000062");
let interval = id("00000000-0000-0000-0000-000000000063");
let a::DefinitionBody::QuantityKind(kind) = &output.definitions[&point].body else {
panic!("quantity kind")
};
assert_eq!(
kind.algebra,
a::QuantityAlgebra::Affine {
difference: a::DefinitionRef::Local(interval)
}
);
let unused = &output.definitions[&id("00000000-0000-0000-0000-000000000064")];
assert_eq!(unused.description, "保留");
let a::DefinitionBody::Calculation(calculation) = &unused.body else {
panic!("calculation")
};
assert_eq!(calculation.body, a::Binding::Unbound);
assert_eq!(
calculation.parameters[0].value_type,
a::AnalysisType::List(a::ScalarType::Quantity(a::DefinitionRef::Local(interval)))
);
assert_eq!(calculation.result, calculation.parameters[0].value_type);
assert!(
output
.occurrences
.iter()
.any(|occurrence| occurrence.field == Field::DocumentedConstraint
&& occurrence.target == Target::Global(requirement))
);
let override_id = id("00000000-0000-0000-0000-000000000067");
assert!(output.occurrences.iter().any(|occurrence| occurrence.field
== Field::Override(override_id)
&& occurrence.role == Role::OverrideTarget));
assert!(
output
.occurrences
.iter()
.any(|occurrence| occurrence.owner == point
&& occurrence.field == Field::Algebra
&& occurrence.target == Target::Global(interval))
);
admit(&merge(skeleton, output));
}
#[test]
fn duplicate_file_keys_and_exhausted_shared_budget_cannot_enter_lowering() {
let mut budget = budget();
let units = [unit("", 3, &mut budget), unit("", 3, &mut budget)];
let failed = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap_err();
assert_eq!(failed.file, Some(3));
assert!(failed.error.message.contains("Duplicate source file"));
let failure = budget
.bytes(usize::MAX, Span { start: 5, end: 6 })
.unwrap_err();
let failed = lower(&ordinary(ORDINARY_OA), &[], &budget).unwrap_err();
assert_eq!(failed.error, failure);
}
#[test]
fn iterative_lowering_preserves_128_deep_and_1024_wide_accepted_expressions() {
let deep = format!("{}1{}", "abs(".repeat(127), ")".repeat(127));
let wide = std::iter::repeat_n("1", 1024)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
r#"
calculation deep "Deep" id "00000000-0000-0000-0000-000000000081" {{ result Number; body {deep}; }}
calculation wide "Wide" id "00000000-0000-0000-0000-000000000082" {{ result List(Number); body [{wide}]; }}
"#
);
let mut budget = budget();
let units = [unit(&source, 1, &mut budget)];
let usage = budget.usage;
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
assert_eq!(output.expressions.len(), 128 + 1025);
let a::DefinitionBody::Calculation(deep) = &output.definitions[&Id::from_u128(0x81)].body
else {
panic!("deep calculation")
};
let a::Binding::Expression(root) = &deep.body else {
panic!("deep expression")
};
let mut current = root;
let mut depth = 1;
while let a::Expr::Unary { operand, .. } = current {
depth += 1;
current = operand;
}
assert_eq!(depth, 128);
assert_eq!(current, &number(1));
let a::DefinitionBody::Calculation(wide) = &output.definitions[&Id::from_u128(0x82)].body
else {
panic!("wide calculation")
};
let a::Binding::Expression(a::Expr::List(values)) = &wide.body else {
panic!("wide list")
};
assert_eq!(values.len(), 1024);
assert!(values.iter().all(|value| *value == number(1)));
assert_eq!(budget.usage, usage);
admit(&merge(ordinary(ORDINARY_OA), output));
}
@@ -0,0 +1,263 @@
//! Independent model construction: never parses source or calls the writer.
use crate::analysis::*;
use crate::analysis_numeric::{Dimension, Exact, ExactWire, WorkLimits, WorkMeter};
use crate::*;
use std::collections::BTreeMap;
pub(super) const NAMES: [(&str, &str); 14] = [
("duration", "Duration"),
("second", "Second"),
("millisecond", "Millisecond"),
("combine", "Combine durations"),
("respond", "Respond"),
("receive", "Receive"),
("dispatch", "Dispatch"),
("receive_time", "Receive time"),
("dispatch_time", "Dispatch time"),
("total_time", "Total time"),
("limit", "Allowed duration"),
("budget", "Response budget"),
("degraded", "Degraded radio"),
("unmeasured", "Unmeasured radio"),
];
fn named(alias: &str) -> String {
NAMES
.iter()
.find(|(key, _)| *key == alias)
.unwrap()
.1
.into()
}
fn local(id: Id) -> DefinitionRef {
DefinitionRef::Local(id)
}
fn quantity(value: i64, unit: Id) -> Expr {
Expr::Quantity {
magnitude: Exact::from_integer(value),
unit: local(unit),
}
}
fn expression(value: Expr) -> Binding {
Binding::Expression(value)
}
fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr {
Expr::Binary {
op,
left: Box::new(left),
right: Box::new(right),
}
}
pub(super) fn project(layer: Layer) -> Project {
// OA is the fixed r2 set; SA independently constructs fresh global IDs.
let id = |text| {
let id = Id::parse_str(text).unwrap();
if layer == Layer::Oa {
id
} else {
Id::from_u128(id.as_u128() ^ (1 << 127))
}
};
let project_id = id("4210b9f2-7046-47f0-aa3b-153f626a579d");
let duration = id("188b911c-5f8b-431c-9eea-2d41f3953767");
let second = id("534f4672-1ba5-4157-8b0a-b9cf41d0a3ce");
let millisecond = id("39053f37-fec9-4708-9f0a-3959d223f891");
let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f");
// Parameter IDs may be reused in another calculation/project.
let a = ParameterId(Id::parse_str("28ff40b0-1b8f-4735-8536-5a6ab21b56cb").unwrap());
let b = ParameterId(Id::parse_str("7a47cc1e-8ae5-4551-b2d8-8a68108e0976").unwrap());
let respond = id("cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59");
let receive = id("4ab9a8a7-7552-488d-9862-e14b89e7b353");
let dispatch = id("6df991ba-4c5e-4535-99e2-524ae274ee8b");
let receive_time = id("63a931c2-675a-4fce-89e5-04361cb99450");
let dispatch_time = id("c33fc772-2a60-4044-9af9-73c61c563ace");
let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b");
let limit = id("437bedbb-2cf4-44ee-a139-6563cc5086ec");
let check = id("bac0de99-9d2f-4e53-9158-d424ecac61bf");
let degraded = id("38036c65-16d4-449c-837b-91eb141c1d93");
let unmeasured = id("99d3e09a-0ec3-4194-9dd4-9f3de6625cde");
let duration_type = || AnalysisType::Scalar(ScalarType::Quantity(local(duration)));
let definition = |id, alias: &str, body| Definition {
id,
alias: alias.into(),
name: named(alias),
description: String::new(),
body,
};
let shared = |id, alias: &str, body| Element {
id,
alias: alias.into(),
name: named(alias),
description: String::new(),
parent: None,
body: ElementBody::shared_in(layer, body),
};
let work = |id, alias: &str, parent| Element {
id,
alias: alias.into(),
name: named(alias),
description: String::new(),
parent,
body: if layer == Layer::Oa {
ElementBody::Oa(OaBody::Activity)
} else {
ElementBody::Sa(SaBody::Function)
},
};
let definitions = [
definition(
duration,
"duration",
DefinitionBody::QuantityKind(QuantityKindDefinition {
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
algebra: QuantityAlgebra::Linear,
}),
),
definition(
second,
"second",
DefinitionBody::Unit(UnitDefinition {
kind: local(duration),
scale: Exact::from_integer(1),
offset: Exact::from_integer(0),
symbol: "s".into(),
}),
),
definition(
millisecond,
"millisecond",
DefinitionBody::Unit(UnitDefinition {
kind: local(duration),
scale: Exact::from_wire(
&ExactWire {
numerator: "1".into(),
denominator: "1000".into(),
},
&mut WorkMeter::new(WorkLimits::REFERENCE),
)
.unwrap(),
offset: Exact::from_integer(0),
symbol: "ms".into(),
}),
),
definition(
combine,
"combine",
DefinitionBody::Calculation(CalculationDefinition {
parameters: vec![
Parameter {
id: a,
name: "a".into(),
value_type: duration_type(),
},
Parameter {
id: b,
name: "b".into(),
value_type: duration_type(),
},
],
result: duration_type(),
body: expression(binary(
BinaryOp::Add,
Expr::Parameter(a),
Expr::Parameter(b),
)),
}),
),
]
.into_iter()
.map(|definition| (definition.id, definition))
.collect();
let property = |id, alias, subject, display, base| {
shared(
id,
alias,
SharedBody::EngineeringProperty {
subject: AnalysisSubject::Element(subject),
value_type: duration_type(),
display_unit: Some(local(display)),
base: expression(base),
},
)
};
let case = |id, alias, binding| {
shared(
id,
alias,
SharedBody::AnalysisCase {
subject: None,
base: BaselineBase::ModelBaseline,
overrides: BTreeMap::from([(receive_time, binding)]),
},
)
};
let elements = [
work(respond, "respond", None),
work(receive, "receive", Some(respond)),
work(dispatch, "dispatch", Some(respond)),
property(
receive_time,
"receive_time",
receive,
millisecond,
quantity(750, millisecond),
),
property(
dispatch_time,
"dispatch_time",
dispatch,
second,
quantity(1, second),
),
property(
total,
"total_time",
respond,
second,
Expr::Call {
definition: local(combine),
arguments: BTreeMap::from([
(a, Expr::Property(receive_time)),
(b, Expr::Property(dispatch_time)),
]),
},
),
property(limit, "limit", respond, second, quantity(2, second)),
shared(
check,
"budget",
SharedBody::AnalysisCheck {
subject: None,
documented_constraint: None,
predicate: expression(binary(
BinaryOp::LessOrEqual,
Expr::Property(total),
Expr::Property(limit),
)),
},
),
case(
degraded,
"degraded",
expression(quantity(1250, millisecond)),
),
case(unmeasured, "unmeasured", Binding::Unbound),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
Project {
schema_version: 8,
id: project_id,
alias: "response_budget".into(),
name: "Response budget".into(),
description: String::new(),
definitions,
elements,
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
}
}
@@ -0,0 +1,473 @@
//! Iterative Pratt machine. Source nesting does not recurse on the native stack.
use super::cursor::Cursor;
use super::*;
use lex::Class;
use std::collections::BTreeSet;
enum SequenceKind {
List,
If,
Sum,
Unary(UnaryOp),
Call(Reference),
}
struct Sequence {
kind: SequenceKind,
start: usize,
values: Vec<(Option<Reference>, Expression)>,
keys: BTreeSet<String>,
}
enum Task {
Expression(u8),
Continue(u8),
Binary(Expression, BinaryOp),
Unary(usize, UnaryOp),
Group(usize),
Next(Sequence),
Collected(Sequence, Option<Reference>),
Power(usize),
AsKind(usize, Reference),
}
pub(super) fn binding(p: &mut Cursor<'_, '_>) -> Result<Binding, Error> {
p.budget.active()?;
if p.text() == "unbound" {
let span = p.span();
p.cursor += 1;
return Ok(Binding::Unbound(span));
}
let mut tasks = vec![Task::Expression(0)];
let mut values = Vec::new();
while let Some(task) = tasks.pop() {
p.budget.active()?;
match task {
Task::Expression(minimum) => {
let start = p.span().start;
if p.text() != "(" {
p.budget.entry(p.span())?;
}
tasks.push(Task::Continue(minimum));
match p.text() {
"true" | "false" => {
let value = p.text() == "true";
let span = p.span();
p.cursor += 1;
values.push(p.node(ExpressionKind::Boolean(value), span)?);
}
"-" => {
p.open()?;
p.cursor += 1;
tasks.push(Task::Unary(start, UnaryOp::Negate));
tasks.push(Task::Expression(7));
}
"(" => {
p.open()?;
p.cursor += 1;
tasks.push(Task::Group(start));
tasks.push(Task::Expression(0));
}
"[" => {
p.open()?;
p.cursor += 1;
tasks.push(Task::Next(Sequence::new(SequenceKind::List, start)));
}
"@" => {
p.cursor += 1;
let reference = p.reference()?;
if p.text() == "(" {
p.open()?;
p.cursor += 1;
tasks.push(Task::Next(Sequence::new(
SequenceKind::Call(reference),
start,
)));
} else {
let end = reference.span.end;
values.push(
p.node(ExpressionKind::Property(reference), Span { start, end })?,
);
}
}
"$" => {
p.cursor += 1;
let reference = p.reference()?;
let end = reference.span.end;
values.push(
p.node(ExpressionKind::Parameter(reference), Span { start, end })?,
);
}
"ratio" => {
let (value, literal) = p.exact_literal()?;
values.push(p.number(value, literal)?);
}
"abs" | "not" | "if" | "sum" => {
let kind = match p.text() {
"abs" => SequenceKind::Unary(UnaryOp::Absolute),
"not" => SequenceKind::Unary(UnaryOp::Not),
"if" => SequenceKind::If,
_ => SequenceKind::Sum,
};
p.cursor += 1;
p.open()?;
p.take("(")?;
tasks.push(Task::Next(Sequence::new(kind, start)));
}
"power" => {
p.cursor += 1;
p.open()?;
p.take("(")?;
tasks.push(Task::Power(start));
tasks.push(Task::Expression(0));
}
"as_kind" => {
p.cursor += 1;
p.open()?;
p.take("(")?;
p.take("@")?;
let kind = p.reference()?;
p.take(",")?;
tasks.push(Task::AsKind(start, kind));
tasks.push(Task::Expression(0));
}
_ if p
.tokens
.get(p.cursor)
.is_some_and(|token| token.class == Class::Number) =>
{
let (value, literal) = p.exact_literal()?;
values.push(p.number(value, literal)?);
}
_ => return Err(error(p.span(), "Expected an analysis expression.")),
}
}
Task::Continue(minimum) => {
if let Some((op, precedence)) =
operator(p.text()).filter(|(_, prec)| *prec >= minimum)
{
let left = values.pop().expect("completed Pratt operand");
if comparison(op) && ungrouped_comparison(&left) {
return Err(error(p.span(), "Comparison children require parentheses."));
}
p.budget.entry(p.span())?;
p.cursor += 1;
tasks.push(Task::Continue(minimum));
tasks.push(Task::Binary(left, op));
tasks.push(Task::Expression(precedence + 1));
}
}
Task::Binary(left, op) => {
let right = values.pop().expect("completed right operand");
if comparison(op) && ungrouped_comparison(&right) {
return Err(error(
right.span,
"Comparison children require parentheses.",
));
}
let span = Span {
start: left.span.start,
end: right.span.end,
};
values.push(p.node(
ExpressionKind::Binary {
op,
left: Box::new(left),
right: Box::new(right),
},
span,
)?);
}
Task::Unary(start, op) => {
let operand = values.pop().expect("completed unary operand");
let end = operand.span.end;
p.close();
values.push(p.node(
ExpressionKind::Unary {
op,
operand: Box::new(operand),
},
Span { start, end },
)?);
}
Task::Group(start) => {
let end = p.take(")")?.end;
p.close();
let value = values.last_mut().expect("completed grouped expression");
value.grouped = true;
value.span = Span { start, end };
}
Task::Next(mut sequence) => {
let closing = if matches!(sequence.kind, SequenceKind::List) {
"]"
} else {
")"
};
if p.text() == closing {
let end = p.take(closing)?.end;
p.close();
values.push(p.sequence(sequence, end)?);
} else {
let count = sequence.values.len();
let maximum = match sequence.kind {
SequenceKind::List => crate::analysis::MAX_LIST_LENGTH,
SequenceKind::Unary(_) => 1,
SequenceKind::If => 3,
SequenceKind::Sum => 2,
SequenceKind::Call(_) => usize::MAX,
};
if count >= maximum {
return Err(error(
p.span(),
"Too many list members or constructor arguments.",
));
}
let key = if matches!(sequence.kind, SequenceKind::Call(_)) {
p.budget.entry(p.span())?;
let key = p.reference()?;
if !sequence.keys.insert(key.name.clone()) {
return Err(error(key.span, "Duplicate call argument name."));
}
p.take(":")?;
Some(key)
} else {
None
};
tasks.push(Task::Collected(sequence, key));
tasks.push(Task::Expression(0));
}
}
Task::Collected(mut sequence, key) => {
sequence
.values
.push((key, values.pop().expect("completed sequence value")));
let closing = if matches!(sequence.kind, SequenceKind::List) {
"]"
} else {
")"
};
if p.text() != closing {
p.take(",")?;
if p.text() == closing {
return Err(error(p.span(), "Trailing comma is not an expression."));
}
}
tasks.push(Task::Next(sequence));
}
Task::Power(start) => {
let base = values.pop().expect("completed power base");
p.take(",")?;
let negative = if p.text() == "-" {
p.cursor += 1;
true
} else {
false
};
let span = p.integer()?;
let magnitude = p.source[span.start..span.end]
.parse::<u8>()
.map_err(|_| error(span, "Power exponent is outside -16..16."))?;
if magnitude > 16 {
return Err(error(span, "Power exponent is outside -16..16."));
}
let exponent = if negative {
-(magnitude as i8)
} else {
magnitude as i8
};
let end = p.take(")")?.end;
p.close();
values.push(p.node(
ExpressionKind::Power {
base: Box::new(base),
exponent,
},
Span { start, end },
)?);
}
Task::AsKind(start, kind) => {
let value = values.pop().expect("completed kind assertion value");
let end = p.take(")")?.end;
p.close();
values.push(p.node(
ExpressionKind::AsKind {
kind,
value: Box::new(value),
},
Span { start, end },
)?);
}
}
}
Ok(Binding::Expression(
values.pop().expect("one complete expression"),
))
}
impl Sequence {
fn new(kind: SequenceKind, start: usize) -> Self {
Self {
kind,
start,
values: Vec::new(),
keys: BTreeSet::new(),
}
}
}
impl Cursor<'_, '_> {
fn number(&mut self, magnitude: Exact, literal: LiteralSyntax) -> Result<Expression, Error> {
let Span { start, end } = literal.span;
let mut expression = if self.text() == "[" {
self.open()?;
self.cursor += 1;
self.take("@")?;
let unit = self.reference()?;
let end = self.take("]")?.end;
self.close();
self.node(
ExpressionKind::Quantity { magnitude, unit },
Span { start, end },
)
} else {
self.node(ExpressionKind::Number(magnitude), Span { start, end })
}?;
expression.literal = Some(literal);
Ok(expression)
}
fn node(&mut self, kind: ExpressionKind, span: Span) -> Result<Expression, Error> {
let child_depth = match &kind {
ExpressionKind::Unary { operand, .. } => operand.depth,
ExpressionKind::Binary { left, right, .. } => left.depth.max(right.depth),
ExpressionKind::If {
condition,
when_true,
when_false,
} => condition.depth.max(when_true.depth).max(when_false.depth),
ExpressionKind::List(values) => values.iter().map(|v| v.depth).max().unwrap_or(0),
ExpressionKind::Call { arguments, .. } => {
arguments.iter().map(|a| a.value.depth).max().unwrap_or(0)
}
ExpressionKind::Sum { values, initial } => values.depth.max(initial.depth),
ExpressionKind::Power { base, .. } => base.depth,
ExpressionKind::AsKind { value, .. } => value.depth,
_ => 0,
};
if child_depth >= crate::analysis::MAX_EXPRESSION_DEPTH {
// The rejected parent still owns accepted, potentially depth-128
// children. Recursive derived cleanup here runs beneath the parser
// frames and can exhaust the bounded native stack before returning
// the depth error. Consume those children with an explicit worklist.
drop_rejected_kind(kind);
return Err(error(span, "Expression logical depth exceeds 128."));
}
Ok(Expression {
kind,
span,
depth: child_depth + 1,
grouped: false,
literal: None,
})
}
fn sequence(&mut self, sequence: Sequence, end: usize) -> Result<Expression, Error> {
let span = Span {
start: sequence.start,
end,
};
let mut values = sequence.values.into_iter();
let mut next = || {
values
.next()
.map(|(_, value)| Box::new(value))
.ok_or_else(|| error(span, "Missing required constructor argument."))
};
let kind = match sequence.kind {
SequenceKind::If => ExpressionKind::If {
condition: next()?,
when_true: next()?,
when_false: next()?,
},
SequenceKind::Sum => ExpressionKind::Sum {
values: next()?,
initial: next()?,
},
SequenceKind::Unary(op) => ExpressionKind::Unary {
op,
operand: next()?,
},
SequenceKind::List => ExpressionKind::List(values.map(|(_, value)| value).collect()),
SequenceKind::Call(definition) => ExpressionKind::Call {
definition,
arguments: values
.map(|(parameter, value)| Argument {
parameter: parameter.expect("parsed named argument"),
value,
})
.collect(),
},
};
self.node(kind, span)
}
}
fn drop_rejected_kind(kind: ExpressionKind) {
let mut pending = vec![kind];
while let Some(kind) = pending.pop() {
match kind {
ExpressionKind::Unary { operand, .. } => pending.push(operand.kind),
ExpressionKind::Binary { left, right, .. } => {
pending.push(left.kind);
pending.push(right.kind);
}
ExpressionKind::If {
condition,
when_true,
when_false,
} => {
pending.push(condition.kind);
pending.push(when_true.kind);
pending.push(when_false.kind);
}
ExpressionKind::List(values) => pending.extend(values.into_iter().map(|v| v.kind)),
ExpressionKind::Call { arguments, .. } => {
pending.extend(arguments.into_iter().map(|a| a.value.kind));
}
ExpressionKind::Sum { values, initial } => {
pending.push(values.kind);
pending.push(initial.kind);
}
ExpressionKind::Power { base, .. } => pending.push(base.kind),
ExpressionKind::AsKind { value, .. } => pending.push(value.kind),
ExpressionKind::Boolean(_)
| ExpressionKind::Number(_)
| ExpressionKind::Quantity { .. }
| ExpressionKind::Property(_)
| ExpressionKind::Parameter(_) => {}
}
}
}
fn comparison(op: BinaryOp) -> bool {
matches!(
op,
BinaryOp::Equal
| BinaryOp::NotEqual
| BinaryOp::Less
| BinaryOp::LessOrEqual
| BinaryOp::Greater
| BinaryOp::GreaterOrEqual
)
}
fn ungrouped_comparison(value: &Expression) -> bool {
!value.grouped && matches!(&value.kind, ExpressionKind::Binary { op, .. } if comparison(*op))
}
fn operator(text: &str) -> Option<(BinaryOp, u8)> {
Some(match text {
"or" => (BinaryOp::Or, 1),
"and" => (BinaryOp::And, 2),
"==" => (BinaryOp::Equal, 3),
"!=" => (BinaryOp::NotEqual, 3),
"<" => (BinaryOp::Less, 4),
"<=" => (BinaryOp::LessOrEqual, 4),
">" => (BinaryOp::Greater, 4),
">=" => (BinaryOp::GreaterOrEqual, 4),
"+" => (BinaryOp::Add, 5),
"-" => (BinaryOp::Subtract, 5),
"*" => (BinaryOp::Multiply, 6),
"/" => (BinaryOp::Divide, 6),
_ => return None,
})
}
@@ -0,0 +1,546 @@
use super::*;
use crate::language::ParseLimits;
use budget::{Budget, Limits};
fn limits() -> Limits {
Limits::source8(ParseLimits::UNLIMITED)
}
fn budget() -> Budget {
Budget::new(limits()).unwrap()
}
fn expression(source: &str) -> Expression {
match parse(source, &mut budget()).unwrap() {
Binding::Expression(value) => value,
Binding::Unbound(_) => panic!("expected expression"),
}
}
fn number(value: &Expression) -> String {
let ExpressionKind::Number(exact) = &value.kind else {
panic!("expected Number");
};
exact.canonical_literal()
}
fn binary_children(value: Expression, expected: BinaryOp) -> (Expression, Expression) {
let ExpressionKind::Binary { op, left, right } = value.kind else {
panic!("expected Binary {expected:?}");
};
assert_eq!(op, expected);
(*left, *right)
}
#[test]
fn mixed_precedence_and_grouping_have_independent_expected_child_topology() {
let (one, product) = binary_children(expression("1 + 2 * 3"), BinaryOp::Add);
assert_eq!(number(&one), "1");
let (two, three) = binary_children(product, BinaryOp::Multiply);
assert_eq!(
(number(&two), number(&three)),
("2".to_owned(), "3".to_owned())
);
let (conjunction, last) =
binary_children(expression("not(false) and true or false"), BinaryOp::Or);
assert_eq!(last.kind, ExpressionKind::Boolean(false));
let (negated, middle) = binary_children(conjunction, BinaryOp::And);
assert_eq!(middle.kind, ExpressionKind::Boolean(true));
let ExpressionKind::Unary { op, operand } = negated.kind else {
panic!("expected Not");
};
assert_eq!(op, UnaryOp::Not);
assert_eq!(operand.kind, ExpressionKind::Boolean(false));
let (negative, two) = binary_children(expression("-1 * 2"), BinaryOp::Multiply);
assert_eq!(number(&two), "2");
let ExpressionKind::Unary { op, operand } = negative.kind else {
panic!("expected Negate");
};
assert_eq!(op, UnaryOp::Negate);
assert_eq!(number(&operand), "1");
let (quotient, three) = binary_children(expression("1 / 2 * 3"), BinaryOp::Multiply);
assert_eq!(number(&three), "3");
let (one, two) = binary_children(quotient, BinaryOp::Divide);
assert_eq!(
(number(&one), number(&two)),
("1".to_owned(), "2".to_owned())
);
let (one, grouped) = binary_children(expression("1 + (2 + 3)"), BinaryOp::Add);
assert_eq!(number(&one), "1");
assert!(grouped.grouped);
let (two, three) = binary_children(grouped, BinaryOp::Add);
assert_eq!(
(number(&two), number(&three)),
("2".to_owned(), "3".to_owned())
);
}
#[test]
fn flat_binary_depth_and_malformed_constructor_retain_bounded_completed_children() {
let flat = |count: usize| vec!["true"; count].join(" and ");
let deep = flat(128);
let mut tree = expression(&deep);
assert_eq!(tree.depth, 128);
// Independently verify the complete left-associated shape, not just depth.
for _ in 1..128 {
let (left, right) = binary_children(tree, BinaryOp::And);
assert_eq!(right.kind, ExpressionKind::Boolean(true));
tree = left;
}
assert_eq!(tree.kind, ExpressionKind::Boolean(true));
assert!(
parse(&flat(129), &mut budget())
.unwrap_err()
.message
.contains("logical depth")
);
// The first argument is already a complete depth128 tree when parsing the
// second fails. Refusal tears down the held child without building Sum.
let malformed = format!("sum({deep}, @)");
let failure = parse(&malformed, &mut budget()).unwrap_err();
assert_eq!(
failure.message,
"Expected a valid reference or parameter name."
);
assert_eq!(&malformed[failure.span.start..failure.span.end], ")");
assert_eq!(number(&expression("1")), "1");
}
#[test]
fn every_parent_shape_rejects_depth129_and_cleans_children_on_128k_stack() {
std::thread::Builder::new()
.name("analysis-syntax-rejection-128k".into())
.stack_size(128 * 1024)
.spawn(|| {
// Rotate through every recursive syntax shape, so the rejected
// parent's complete child tree also exercises every cleanup arm.
let wrap = |shape, child: &str| match shape {
0 => format!("abs({child})"),
1 => format!("({child}) + 1"),
2 => format!("if(true, {child}, 0)"),
3 => format!("[{child}]"),
4 => format!("@f(a: {child})"),
5 => format!("sum({child}, 0)"),
6 => format!("power({child}, 1)"),
7 => format!("as_kind(@k, {child})"),
_ => unreachable!(),
};
let mut deep = "1".to_owned();
for layer in 0..127 {
deep = wrap(layer % 8, &deep);
}
assert_eq!(expression(&deep).depth, 128);
for shape in 0..8 {
let source = wrap(shape, &deep);
let failure = parse(&source, &mut budget()).unwrap_err();
assert_eq!(failure.message, "Expression logical depth exceeds 128.");
assert_eq!(
failure.span,
Span {
start: 0,
end: source.len()
}
);
assert_eq!(number(&expression("1")), "1");
}
})
.unwrap()
.join()
.unwrap();
}
#[test]
fn significant_ratio_and_quantity_trivia_preserve_original_spans() {
let source = "ratio // explanation café\n ( - // sign\n 3, // denominator\n 4 ) // measurement\n [@millisecond]";
let parsed = expression(source);
assert_eq!(
parsed.span,
Span {
start: 0,
end: source.len()
}
);
let literal = parsed.literal.as_ref().unwrap();
assert!(source[literal.span.start..literal.span.end].contains("// denominator"));
assert_eq!(
literal
.tokens
.iter()
.map(|span| &source[span.start..span.end])
.collect::<Vec<_>>(),
["ratio", "(", "-", "3", ",", "4", ")"]
);
let ExpressionKind::Quantity { magnitude, unit } = parsed.kind else {
panic!("expected Quantity");
};
assert_eq!(magnitude.canonical_literal(), "ratio(-3, 4)");
assert_eq!(unit.name, "millisecond");
assert_eq!(&source[unit.span.start..unit.span.end], "millisecond");
let mut renamed = source.to_owned();
renamed.replace_range(unit.span.start..unit.span.end, "ms");
assert_eq!(renamed, source.replacen("millisecond", "ms", 1));
assert_eq!(
number(&expression("ratio (1, // rationale\n 2)")),
"ratio(1, 2)"
);
for invalid in [
"1 e - 2",
"1e // x\n -2",
"1 . 2",
"ratio(1 + 2, 3)",
"ratio(1, -2)",
"ratio(+1, 2)",
"ratio(1.0, 2)",
"ratio(1, 0)",
] {
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
}
}
#[test]
fn raw_numeric_lexing_preserves_exact_atoms_and_operator_boundaries() {
let source = "1-2 1e-2 ratio(-1,2) -(1) <= >= == !=";
let tokens = lex::tokens(source, &mut budget()).unwrap();
let text: Vec<_> = tokens
.iter()
.map(|t| &source[t.span.start..t.span.end])
.collect();
assert_eq!(
text,
[
"1", "-", "2", "1e-2", "ratio", "(", "-", "1", ",", "2", ")", "-", "(", "1", ")", "<=",
">=", "==", "!="
]
);
let huge = "9".repeat(400);
assert_eq!(
lex::tokens(&huge, &mut budget()).unwrap()[0].class,
lex::Class::Number
);
assert_eq!(number(&expression(&huge)), huge);
for invalid in ["1e", "1e+", "1_0", "0x1", "1.2.3"] {
assert!(lex::tokens(invalid, &mut budget()).is_err(), "{invalid}");
}
let quoted = "\"quoted\\\" café\"";
let tokens = lex::tokens(quoted, &mut budget()).unwrap();
assert_eq!(tokens[0].class, lex::Class::String);
assert_eq!(&quoted[tokens[0].span.start..tokens[0].span.end], quoted);
}
#[test]
fn unary_literals_precedence_and_comparison_children_keep_distinct_trees() {
assert_eq!(number(&expression("ratio(-1, 1)")), "ratio(-1, 1)");
let negative = expression("-(1)");
assert!(matches!(
negative.kind,
ExpressionKind::Unary {
op: UnaryOp::Negate,
..
}
));
let left = expression("(1 - 2) - 3");
let right = expression("1 - (2 - 3)");
let ExpressionKind::Binary {
left, right: last, ..
} = left.kind
else {
panic!();
};
assert!(matches!(
left.kind,
ExpressionKind::Binary {
op: BinaryOp::Subtract,
..
}
));
assert_eq!(number(&last), "3");
let ExpressionKind::Binary {
left: first, right, ..
} = right.kind
else {
panic!();
};
assert_eq!(number(&first), "1");
assert!(matches!(
right.kind,
ExpressionKind::Binary {
op: BinaryOp::Subtract,
..
}
));
for valid in [
"(1 < 2) == true",
"(true == false) == true",
"true == (1 < 2)",
"1 + 2 * 3",
"not(false) and true or false",
] {
parse(valid, &mut budget()).unwrap();
}
for invalid in [
"1 < 2 == true",
"true == false == true",
"1 < 2 < 3",
"true == 1 < 2",
] {
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
}
}
#[test]
fn every_expression_shape_and_exact_constructor_arity_is_represented() {
let tree = expression("if(true, sum([@p, $x], 0), as_kind(@duration, power(abs(-(2)), -2)))");
let ExpressionKind::If {
condition,
when_true,
when_false,
} = tree.kind
else {
panic!();
};
assert_eq!(condition.kind, ExpressionKind::Boolean(true));
let ExpressionKind::Sum { values, initial } = when_true.kind else {
panic!();
};
assert_eq!(number(&initial), "0");
let ExpressionKind::List(values) = values.kind else {
panic!();
};
assert!(matches!(&values[0].kind, ExpressionKind::Property(r) if r.name == "p"));
assert!(matches!(&values[1].kind, ExpressionKind::Parameter(r) if r.name == "x"));
let ExpressionKind::AsKind { kind, value } = when_false.kind else {
panic!();
};
assert_eq!(kind.name, "duration");
assert!(matches!(
value.kind,
ExpressionKind::Power { exponent: -2, .. }
));
assert!(matches!(expression("[]").kind, ExpressionKind::List(v) if v.is_empty()));
assert!(
matches!(expression("@zero()").kind, ExpressionKind::Call { arguments, .. } if arguments.is_empty())
);
assert!(matches!(
parse(" unbound // pending", &mut budget()).unwrap(),
Binding::Unbound(_)
));
for invalid in [
"sum([1])",
"sum([1], 0, 2)",
"if(true, 1)",
"abs()",
"not(true, false)",
"power(2, 17)",
"power(2, 1.0)",
"as_kind(duration, 1)",
"[1,]",
"[1 2]",
"(1+2)[@unit]",
] {
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
}
}
#[test]
fn repeated_call_argument_occurrences_have_distinct_original_spans() {
let source = "@combine(a: @p, b: 2) + @combine(a: @q, b: 4)";
let tree = expression(source);
let ExpressionKind::Binary { left, right, .. } = tree.kind else {
panic!();
};
let ExpressionKind::Call {
definition: first,
arguments: a,
} = left.kind
else {
panic!();
};
let ExpressionKind::Call {
definition: second,
arguments: b,
} = right.kind
else {
panic!();
};
assert_eq!(first.name, second.name);
assert_ne!(first.span, second.span);
assert_eq!(a[0].parameter.name, b[0].parameter.name);
assert_ne!(a[0].parameter.span, b[0].parameter.span);
for argument in a.iter().chain(&b) {
assert_eq!(
&source[argument.parameter.span.start..argument.parameter.span.end],
argument.parameter.name
);
}
assert!(
parse("@combine(a: 1, a: 2)", &mut budget())
.unwrap_err()
.message
.contains("Duplicate")
);
assert!(parse("@combine(a: 1, b:)", &mut budget()).is_err());
// Resolution to owner-qualified ParameterId and actual source rewrites are
// deliberately a later stage; this oracle establishes unique syntax spans.
}
#[test]
fn budgets_aggregate_across_fragments_and_latch_numeric_exhaustion() {
let mut first = budget();
parse("ratio(1, 3)", &mut first).unwrap();
let cost = first.numeric_units();
assert!(cost > 0);
let mut settings = limits();
settings.numeric_units = cost;
let mut shared = Budget::new(settings).unwrap();
parse("ratio (1, // same numeric cost\n 3)", &mut shared).unwrap();
assert_eq!(shared.numeric_units(), cost);
let failure = parse("2", &mut shared).unwrap_err();
let usage = shared.usage;
assert_eq!(parse("unbound", &mut shared).unwrap_err(), failure);
assert_eq!(shared.usage, usage);
parse("2", &mut budget()).unwrap();
let mut settings = limits();
settings.entries = 2;
let mut shared = Budget::new(settings).unwrap();
parse("true", &mut shared).unwrap();
parse("false", &mut shared).unwrap();
assert!(
parse("true", &mut shared)
.unwrap_err()
.message
.contains("entries")
);
assert_eq!(shared.usage.entries, 2);
let mut settings = limits();
settings.references = 1;
let mut shared = Budget::new(settings).unwrap();
parse("@one", &mut shared).unwrap();
assert!(
parse("$two", &mut shared)
.unwrap_err()
.message
.contains("references")
);
assert_eq!(shared.usage.references, 1);
}
#[test]
fn byte_token_frame_logical_and_list_boundaries_have_nearby_successes() {
let mut settings = limits();
settings.bytes = 4;
let mut shared = Budget::new(settings).unwrap();
parse("true", &mut shared).unwrap();
assert!(
parse(" ", &mut shared)
.unwrap_err()
.message
.contains("bytes")
);
assert_eq!(shared.usage.bytes, 4);
let mut settings = limits();
settings.tokens = 2;
let mut shared = Budget::new(settings).unwrap();
parse("true ", &mut shared).unwrap();
assert!(
parse("// note", &mut shared)
.unwrap_err()
.message
.contains("tokens")
);
assert_eq!(shared.usage.tokens, 2);
let nested = |depth: usize| format!("{}true{}", "(".repeat(depth), ")".repeat(depth));
let mut shared = budget();
assert_eq!(
match parse(&nested(256), &mut shared).unwrap() {
Binding::Expression(e) => e.depth,
_ => 0,
},
1
);
assert_eq!(shared.usage.deepest_frame, 256);
assert!(
parse(&nested(257), &mut budget())
.unwrap_err()
.message
.contains("frames")
);
let unary = |depth: usize| format!("{}true{}", "not(".repeat(depth - 1), ")".repeat(depth - 1));
assert_eq!(expression(&unary(128)).depth, 128);
assert!(
parse(&unary(129), &mut budget())
.unwrap_err()
.message
.contains("logical depth")
);
let list = |count: usize| format!("[{}]", vec!["true"; count].join(","));
parse(&list(1024), &mut budget()).unwrap();
assert!(parse(&list(1025), &mut budget()).is_err());
let lowered = Limits::source8(ParseLimits {
max_source_bytes: 8,
max_graph_objects: 3,
max_records: 5,
max_step_references: 2,
});
assert_eq!(
(
lowered.bytes,
lowered.tokens,
lowered.entries,
lowered.references
),
(8, 8, 5, 2)
);
let mut above = limits();
above.frames += 1;
assert!(Budget::new(above).is_err());
}
#[test]
fn partial_wide_and_deep_input_cleanup_does_not_poison_a_fresh_parse() {
let source = "@f(a: if(true, [ratio (1, //why\n 2), @p], sum([1, 2], 0)), b: power(2, -3))";
let tokens = lex::tokens(source, &mut budget()).unwrap();
for token in tokens.iter().filter(|token| {
matches!(
&source[token.span.start..token.span.end],
"(" | "[" | "," | ":" | "@"
)
}) {
assert!(parse(&source[..token.span.end], &mut budget()).is_err());
assert_eq!(number(&expression("ratio(2, 3)")), "ratio(2, 3)");
}
for malformed in [
format!("{}true", "(".repeat(256)),
format!("[{}", vec!["true"; 1024].join(",")),
"-".repeat(10_000),
] {
assert!(parse(&malformed, &mut budget()).is_err());
parse("true", &mut budget()).unwrap();
}
}
#[test]
fn public_source_headers_and_existing_numeric_token_controls_are_unchanged() {
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
for accepted in ["-1.5", "1e-2", "0", "-0"] {
let tokens = crate::language::tokenize(accepted);
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].class, crate::language::TokenClass::Number);
assert_eq!(
tokens[0].span,
Span {
start: 0,
end: accepted.len()
}
);
}
for rejected in ["+1", "1_0", "0x1", "1e"] {
assert!(
crate::language::tokenize(rejected)
.iter()
.any(|t| t.class == crate::language::TokenClass::Error)
);
}
}
@@ -0,0 +1,316 @@
//! Simultaneous typed token edits. This is a staged plan, not source acceptance:
//! the bundle owner must merge ordinary edits, reparse/recompile the exact bytes,
//! admit once and compare the complete intended Project before installation.
use super::index::{Index, Location, OccurrenceKey, checked_source};
use super::lower::Target;
use super::{Budget, Error, Span, error};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
struct Edit {
key: OccurrenceKey,
location: Location,
}
#[derive(Debug)]
pub(super) struct Plan<'i, 's> {
index: &'i Index<'s>,
names: BTreeMap<Target, String>,
edits: Vec<Edit>,
}
impl<'i, 's> Plan<'i, 's> {
/// All names are the desired final names. Validate the final namespaces,
/// rather than applying one rename at a time: alias/parameter swaps are legal.
pub fn renames(
index: &'i Index<'s>,
renames: &BTreeMap<Target, String>,
budget: &Budget,
) -> Result<Self, Error> {
let plan = Self::prepare(index, renames, budget)?;
plan.validate(index.sources(), budget)?;
plan.output_lengths(index.sources(), budget)?;
Ok(plan)
}
fn validate_names(
index: &Index<'_>,
renames: &BTreeMap<Target, String>,
budget: &Budget,
) -> Result<(), Error> {
budget.active()?;
budget.lowering_entries(renames.len(), Span::default())?;
let mut bytes = 0usize;
for (target, name) in renames {
if !index.names().contains_key(target) {
return Err(error(
Span::default(),
"Rename has no qualified source target.",
));
}
if !crate::valid_alias(name) {
return Err(error(
Span::default(),
"Invalid alias or scoped parameter name.",
));
}
bytes = bytes
.checked_add(name.len())
.ok_or_else(|| error(Span::default(), "Rename size overflow."))?;
budget.index_bytes(bytes, Span::default())?;
}
let mut final_names = BTreeSet::new();
for (target, original) in index.names() {
let name = renames.get(target).unwrap_or(original);
let scope = match target {
Target::Global(_) => None,
Target::Parameter { calculation, .. } => Some(*calculation),
};
budget.lowering_entries(final_names.len() + 1, Span::default())?;
if !final_names.insert((scope, name.as_str())) {
return Err(error(
Span::default(),
"Rename creates a duplicate name in its namespace.",
));
}
}
Ok(())
}
fn prepare(
index: &'i Index<'s>,
renames: &BTreeMap<Target, String>,
budget: &Budget,
) -> Result<Self, Error> {
// Validate record/name storage before copying any owned final names.
Self::validate_names(index, renames, budget)?;
let mut edits = Vec::new();
for (key, location) in index.occurrences() {
if renames
.get(&key.target)
.is_some_and(|name| index.names().get(&key.target) != Some(name))
{
budget.lowering_occurrences(edits.len() + 1, location.span)?;
edits.push(Edit {
key: key.clone(),
location: *location,
});
}
}
edits.sort_by_key(|edit| {
(
edit.location.file,
edit.location.span.start,
edit.location.span.end,
)
});
Ok(Self {
index,
names: renames.clone(),
edits,
})
}
pub fn edit_count(&self) -> usize {
self.edits.len()
}
/// Recheck the complete original witness and complete rename intent.
/// A changed unrelated file is stale too. This does not construct output.
fn validate(&self, current: &BTreeMap<usize, String>, budget: &Budget) -> Result<(), Error> {
budget.active()?;
if current != self.index.sources() {
return Err(error(
Span::default(),
"Source index is stale: original file set or bytes changed.",
));
}
budget.lowering_entries(current.len(), Span::default())?;
budget.lowering_occurrences(self.edits.len(), Span::default())?;
let mut previous: Option<Location> = None;
let mut seen = BTreeSet::new();
for edit in &self.edits {
let at = edit.location;
if self.index.occurrences().get(&edit.key) != Some(&at) || !seen.insert(&edit.key) {
return Err(error(at.span, "Forged or duplicate edit occurrence."));
}
let old = checked_source(current, at)?;
if self
.index
.names()
.get(&edit.key.target)
.is_none_or(|name| name != old)
{
return Err(error(at.span, "Stale original token spelling."));
}
self.names
.get(&edit.key.target)
.ok_or_else(|| error(at.span, "Edit has no final target name."))?;
if previous.is_some_and(|prior| {
prior.file > at.file || (prior.file == at.file && prior.span.end > at.span.start)
}) {
return Err(error(at.span, "Overlapping or unordered source edits."));
}
previous = Some(at);
}
for (key, at) in self.index.occurrences() {
let required = self
.names
.get(&key.target)
.is_some_and(|name| self.index.names().get(&key.target) != Some(name));
if required != seen.contains(key) {
return Err(error(
at.span,
"Edit set differs from the complete qualified rename plan.",
));
}
}
Ok(())
}
/// Standalone output preflight, after ownership validation. Composition
/// defers this hypothetical typed-only size to its future combined emitter.
fn output_lengths(
&self,
current: &BTreeMap<usize, String>,
budget: &Budget,
) -> Result<BTreeMap<usize, usize>, Error> {
let mut lengths: BTreeMap<_, _> = current
.iter()
.map(|(&file, source)| (file, source.len()))
.collect();
for edit in &self.edits {
let at = edit.location;
let length = lengths.get_mut(&at.file).expect("validated edit file");
*length = length
.checked_sub(at.span.end - at.span.start)
.and_then(|size| size.checked_add(self.names[&edit.key.target].len()))
.ok_or_else(|| error(at.span, "Updated source size overflow."))?;
}
let mut total = 0usize;
for length in lengths.values() {
total = total
.checked_add(*length)
.ok_or_else(|| error(Span::default(), "Updated bundle size overflow."))?;
budget.index_bytes(total, Span::default())?;
}
Ok(lengths)
}
pub fn apply(
&self,
current: &BTreeMap<usize, String>,
budget: &Budget,
) -> Result<BTreeMap<usize, String>, Error> {
self.validate(current, budget)?;
let lengths = self.output_lengths(current, budget)?;
let mut result = BTreeMap::new();
let mut edits = self.edits.iter().peekable();
for (&file, source) in current {
let mut output = String::with_capacity(lengths[&file]);
let mut start = 0;
while edits.peek().is_some_and(|edit| edit.location.file == file) {
let edit = edits.next().expect("peeked edit");
output.push_str(&source[start..edit.location.span.start]);
output.push_str(&self.names[&edit.key.target]);
start = edit.location.span.end;
}
output.push_str(&source[start..]);
result.insert(file, output);
}
Ok(result)
}
}
/// Private typed-only composition planning. The caller must supply its inherited
/// parse Budget; this type keeps that same immutable budget through export.
/// It does not establish the future complete bundle/file-table/parse-session
/// witness. In particular, an Index currently does not store its parse profile.
pub(super) struct Composition<'b, 'i, 's> {
plan: Plan<'i, 's>,
budget: &'b Budget,
}
impl std::fmt::Debug for Composition<'_, '_, '_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Composition")
.field("plan", &self.plan)
.finish_non_exhaustive()
}
}
impl<'b, 'i, 's> Composition<'b, 'i, 's> {
pub fn renames(
index: &'i Index<'s>,
renames: &BTreeMap<Target, String>,
inherited: &'b Budget,
) -> Result<Self, Error> {
let plan = Plan::prepare(index, renames, inherited)?;
plan.validate(index.sources(), inherited)?;
Ok(Self {
plan,
budget: inherited,
})
}
/// Export the entire validated plan against exact original numeric file keys
/// and bytes. There is no output allocation, application or subset-accept API.
/// A future collector must check this actual index witness and the complete
/// combined output bounds before applying either producer's edits once.
pub fn original_edits(
&self,
current: &BTreeMap<usize, String>,
) -> Result<OriginalEdits<'_, 'i, 's>, Error> {
Plan::validate_names(self.plan.index, &self.plan.names, self.budget)?;
self.plan.validate(current, self.budget)?;
Ok(OriginalEdits { plan: &self.plan })
}
}
#[derive(Debug)]
pub(super) struct OriginalEdits<'p, 'i, 's> {
plan: &'p Plan<'i, 's>,
}
impl<'s> OriginalEdits<'_, '_, 's> {
pub fn index(&self) -> &Index<'s> {
self.plan.index
}
pub fn len(&self) -> usize {
self.plan.edits.len()
}
pub fn is_empty(&self) -> bool {
self.plan.edits.is_empty()
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = OriginalEdit<'_>> {
self.plan.edits.iter().map(|edit| OriginalEdit {
key: &edit.key,
location: edit.location,
original: &self.plan.index.sources()[&edit.location.file]
[edit.location.span.start..edit.location.span.end],
replacement: &self.plan.names[&edit.key.target],
})
}
}
/// A borrowed inspection record, never independent edit authority. Only an
/// intact OriginalEdits batch can carry this producer's complete rename intent.
#[derive(Debug)]
pub(super) struct OriginalEdit<'p> {
key: &'p OccurrenceKey,
location: Location,
original: &'p str,
replacement: &'p str,
}
impl OriginalEdit<'_> {
pub fn key(&self) -> &OccurrenceKey {
self.key
}
pub fn location(&self) -> Location {
self.location
}
pub fn original(&self) -> &str {
self.original
}
pub fn replacement(&self) -> &str {
self.replacement
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,744 @@
use super::super::budget::Limits;
use super::*;
use crate::analysis as a;
use crate::analysis_numeric::Exact;
use crate::language::ParseLimits;
use crate::{Id, Layer, Project};
const DEFINITIONS: &str = "// 雪 @f(a:) $a: comment spellings stay exact\r\n\
calculation f \"f a b\" id \"00000000-0000-0000-0000-000000000100\" {\r\n\
input a id \"00000000-0000-0000-0000-000000000200\" : Number;\r\n\
input b id \"00000000-0000-0000-0000-000000000201\" : Number;\r\n\
result Number; body $a + $b;\r\n}\r\n\
calculation g \"g a b\" id \"00000000-0000-0000-0000-000000000200\" {\r\n\
input a id \"00000000-0000-0000-0000-000000000200\" : Number;\r\n\
input b id \"00000000-0000-0000-0000-000000000201\" : Number;\r\n\
result Number; body $a + $b;\r\n}\r\n";
const CALLER: &str = "calculation caller \"Caller\" id \"00000000-0000-0000-0000-000000000300\" {\n\
result Number; body @f(b: 2, a: 1) // first 雪 @f(a:)\n\
+ @f(a: 3, b: 4) // second\n\
+ @g(a: 5, b: 6);\n}\n";
fn budget() -> Budget {
Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap()
}
fn global(id: u128) -> Target {
Target::Global(Id::from_u128(id))
}
fn parameter(calculation: u128, parameter: u128) -> Target {
Target::Parameter {
calculation: Id::from_u128(calculation),
parameter: a::ParameterId(Id::from_u128(parameter)),
}
}
fn sources() -> BTreeMap<usize, String> {
BTreeMap::from([(11, DEFINITIONS.into()), (23, CALLER.into())])
}
fn skeleton() -> Project {
let mut project = super::super::model_oracle::project(Layer::Oa);
project.definitions.clear();
project
.elements
.retain(|_, element| !element.kind().is_analysis());
project
}
fn binary(left: a::Expr, right: a::Expr) -> a::Expr {
a::Expr::Binary {
op: a::BinaryOp::Add,
left: Box::new(left),
right: Box::new(right),
}
}
fn expected(renamed: bool) -> Project {
// Independent model construction: no parsed AST, source index, writer or
// generated edit participates in this expected complete Project.
let mut project = skeleton();
let p = |id| a::ParameterId(Id::from_u128(id));
for (id, alias, name) in [
(0x100, if renamed { "g" } else { "f" }, "f a b"),
(0x200, if renamed { "f" } else { "g" }, "g a b"),
] {
let names = if renamed && id == 0x100 {
["b", "a"]
} else {
["a", "b"]
};
let body = a::CalculationDefinition {
parameters: [(0x200, names[0]), (0x201, names[1])]
.into_iter()
.map(|(id, name)| a::Parameter {
id: p(id),
name: name.into(),
value_type: a::AnalysisType::Scalar(a::ScalarType::Number),
})
.collect(),
result: a::AnalysisType::Scalar(a::ScalarType::Number),
body: a::Binding::Expression(binary(
a::Expr::Parameter(p(0x200)),
a::Expr::Parameter(p(0x201)),
)),
};
let id = Id::from_u128(id);
project.definitions.insert(
id,
a::Definition {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
body: a::DefinitionBody::Calculation(body),
},
);
}
let call = |id, x, y| a::Expr::Call {
definition: a::DefinitionRef::Local(Id::from_u128(id)),
arguments: BTreeMap::from([
(p(0x200), a::Expr::Number(Exact::from_integer(x))),
(p(0x201), a::Expr::Number(Exact::from_integer(y))),
]),
};
let id = Id::from_u128(0x300);
project.definitions.insert(
id,
a::Definition {
id,
alias: "caller".into(),
name: "Caller".into(),
description: String::new(),
body: a::DefinitionBody::Calculation(a::CalculationDefinition {
parameters: Vec::new(),
result: a::AnalysisType::Scalar(a::ScalarType::Number),
body: a::Binding::Expression(binary(
binary(call(0x100, 1, 2), call(0x100, 3, 4)),
call(0x200, 5, 6),
)),
}),
},
);
project
}
fn merge(mut project: Project, lowered: super::super::lower::Lowered) -> Project {
project.definitions.extend(lowered.definitions);
project.elements.extend(lowered.elements);
project
}
fn rename_map() -> BTreeMap<Target, String> {
BTreeMap::from([
(global(0x100), "g".into()),
(global(0x200), "f".into()),
(parameter(0x100, 0x200), "b".into()),
(parameter(0x100, 0x201), "a".into()),
])
}
#[test]
fn simultaneous_global_and_scoped_swaps_match_original_token_and_whole_model_oracles() {
let sources = sources();
let mut work = budget();
let (index, lowered) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
assert_eq!(merge(skeleton(), lowered), expected(false));
let plan = Plan::renames(&index, &rename_map(), &work).unwrap();
// List expected edits against the original authored bytes independently of
// the index. Full contexts deliberately exclude equal strings/comments.
let mut edits = Vec::new();
let mut add = |file, context: &str, token: &str, replacement: &str| {
let source = &sources[&file];
let context_start = source.find(context).unwrap();
let start = context_start + context.find(token).unwrap();
edits.push((file, start, start + token.len(), replacement.to_owned()));
};
add(11, "calculation f ", "f", "g");
add(11, "calculation g ", "g", "f");
add(11, "input a id", "a", "b");
add(11, "input b id", "b", "a");
add(11, "body $a + $b;", "a", "b");
add(11, "body $a + $b;", "b;", "a;");
// The last oracle edit above includes ';' to disambiguate 'b' in 'body';
// restrict it back to the exact name token for the span oracle.
edits.last_mut().unwrap().2 -= 1;
edits.last_mut().unwrap().3.pop();
for call in ["@f(b: 2, a: 1)", "@f(a: 3, b: 4)"] {
add_call_edits(&sources, &mut edits, call);
}
let start = CALLER.find("@g(a: 5, b: 6)").unwrap() + 1;
edits.push((23, start, start + 1, "f".into()));
edits.sort_by_key(|edit| (edit.0, edit.1));
assert_eq!(plan.edit_count(), 13);
assert_eq!(
plan.edits
.iter()
.map(|edit| (
edit.location.file,
edit.location.span.start,
edit.location.span.end,
plan.names[&edit.key.target].clone()
))
.collect::<Vec<_>>(),
edits
);
let mut exact = BTreeMap::new();
for (&file, source) in &sources {
let mut output = source.clone();
for (_, start, end, value) in edits.iter().filter(|edit| edit.0 == file).rev() {
output.replace_range(*start..*end, value);
}
exact.insert(file, output);
}
let output = plan.apply(&sources, &work).unwrap();
assert_eq!(output, exact);
assert!(output[&11].contains("// 雪 @f(a:) $a: comment spellings stay exact\r\n"));
assert!(output[&23].contains("// first 雪 @f(a:)\n"));
assert!(output[&11].contains("\"f a b\""));
let (_, lowered) = Index::parse(&skeleton(), &output, &mut budget()).unwrap();
let actual = merge(skeleton(), lowered);
assert_eq!(actual, expected(true));
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
}
fn add_call_edits(
sources: &BTreeMap<usize, String>,
edits: &mut Vec<(usize, usize, usize, String)>,
call: &str,
) {
let start = sources[&23].find(call).unwrap();
for (offset, replacement) in [
(1, "g"),
(call.find("a:").unwrap(), "b"),
(call.find("b:").unwrap(), "a"),
] {
edits.push((23, start + offset, start + offset + 1, replacement.into()));
}
}
#[test]
fn alias_edits_preserve_unit_type_override_tokens_and_unrelated_authored_bytes() {
let source = include_str!("../lower/fixtures/typed-oa.arcadia")
.replace("750 [@millisecond]", "750 // exact 雪\r\n [@millisecond]");
let files = BTreeMap::from([(59, source.clone())]);
let mut work = budget();
let (index, _) = Index::parse(&skeleton(), &files, &mut work).unwrap();
let mut expected_project = super::super::model_oracle::project(Layer::Oa);
let id = |name: &str| {
index
.names()
.iter()
.find(|(target, alias)| matches!(target, Target::Global(_)) && alias.as_str() == name)
.map(|(target, _)| *target)
.unwrap()
};
let renames = BTreeMap::from([
(id("duration"), "time_kind".into()),
(id("millisecond"), "ms_unit".into()),
(id("receive_time"), "arrival".into()),
]);
let plan = Plan::renames(&index, &renames, &work).unwrap();
let output = plan.apply(&files, &work).unwrap();
let expected_source = source
.replace("quantity_kind duration ", "quantity_kind time_kind ")
.replace("@duration", "@time_kind")
.replace("unit millisecond ", "unit ms_unit ")
.replace("@millisecond", "@ms_unit")
.replace("oa_property receive_time ", "oa_property arrival ")
.replace("@receive_time", "@arrival");
assert_eq!(output[&59], expected_source);
for definition in expected_project.definitions.values_mut() {
if let Some(name) = renames.get(&Target::Global(definition.id)) {
definition.alias = name.clone();
}
}
for element in expected_project.elements.values_mut() {
if let Some(name) = renames.get(&Target::Global(element.id)) {
element.alias = name.clone();
}
}
let (_, lowered) = Index::parse(&skeleton(), &output, &mut budget()).unwrap();
assert_eq!(merge(skeleton(), lowered), expected_project);
}
#[test]
fn rename_rejects_unknown_invalid_and_colliding_final_names_but_allows_noop() {
let sources = sources();
let work = &mut budget();
let (index, _) = Index::parse(&skeleton(), &sources, work).unwrap();
for renames in [
BTreeMap::from([(global(999), "new".into())]),
BTreeMap::from([(global(0x100), "bad name".into())]),
BTreeMap::from([(global(0x100), "g".into())]),
BTreeMap::from([(parameter(0x100, 0x200), "b".into())]),
] {
assert!(Plan::renames(&index, &renames, work).is_err());
}
let plan = Plan::renames(&index, &BTreeMap::from([(global(0x100), "f".into())]), work).unwrap();
assert_eq!(plan.edit_count(), 0);
assert_eq!(plan.apply(&sources, work).unwrap(), sources);
}
#[test]
fn stale_file_sets_bytes_forged_spans_and_duplicate_edits_fail_before_output() {
let sources = sources();
let mut work = budget();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
for damage in 0..6 {
let mut current = sources.clone();
let mut plan = Plan::renames(&index, &rename_map(), &work).unwrap();
match damage {
0 => current
.get_mut(&23)
.unwrap()
.push_str("// unrelated change"),
1 => {
current.remove(&23);
}
2 => {
current.insert(99, String::new());
}
3 => plan.edits[0].location.span.start += 1,
4 => plan.edits.insert(1, plan.edits[0].clone()),
_ => {
plan.edits.pop();
}
}
assert!(plan.apply(&current, &work).is_err(), "damage {damage}");
}
}
#[test]
fn output_and_plan_limits_are_aggregate_and_do_not_reset_parse_work() {
let sources = sources();
let mut work = budget();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
let usage = work.usage;
let numeric = work.numeric_units();
let plan = Plan::renames(&index, &rename_map(), &work).unwrap();
let output = plan.apply(&sources, &work).unwrap();
assert_eq!(work.usage, usage);
assert_eq!(work.numeric_units(), numeric);
let bytes = output.values().map(String::len).sum();
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.bytes = bytes;
assert!(plan.apply(&sources, &Budget::new(limits).unwrap()).is_ok());
limits.bytes -= 1;
assert!(plan.apply(&sources, &Budget::new(limits).unwrap()).is_err());
limits = Limits::source8(ParseLimits::UNLIMITED);
limits.references = 0;
limits.entries = 0;
assert!(Plan::renames(&index, &rename_map(), &Budget::new(limits).unwrap()).is_err());
}
#[test]
fn composition_exports_complete_borrowed_swap_witnesses_and_empty_noops() {
use super::super::index::TokenRole;
use super::super::lower::{Child, Field, Role};
type Key = (Id, Option<Field>, Option<Vec<Child>>, TokenRole, Target);
type Row = (Location, String, String, Key);
let sources = sources();
let mut work = budget();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
let usage = work.usage;
let numeric = work.numeric_units();
let composition = Composition::renames(&index, &rename_map(), &work).unwrap();
assert!(std::ptr::eq(composition.budget, &work));
// A byte-identical snapshot is valid; records still borrow the actual index.
let batch = composition.original_edits(&sources.clone()).unwrap();
assert!(std::ptr::eq(batch.index(), &index));
assert_eq!(batch.len(), 13);
assert!(!batch.is_empty());
assert_eq!(batch.iter().len(), 13);
// Independently authored occurrence identities and full source contexts.
// No index offset/path or generated output defines this oracle.
let mut expected: Vec<Row> = Vec::new();
let mut add = |file, context: &str, prefix: &str, old: &str, new: &str, key: Key| {
let start = sources[&file].find(context).unwrap() + prefix.len();
assert_eq!(&sources[&file][start..start + old.len()], old);
expected.push((
Location {
file,
span: Span {
start,
end: start + old.len(),
},
},
old.into(),
new.into(),
key,
));
};
for (id, old, new, context) in [
(0x100, "f", "g", "calculation f "),
(0x200, "g", "f", "calculation g "),
] {
add(
11,
context,
"calculation ",
old,
new,
(
Id::from_u128(id),
None,
None,
TokenRole::Declaration,
global(id),
),
);
}
for (id, old, new, context, edge, prefix) in [
(0x200, "a", "b", "input a id", Child::Left, "body $"),
(0x201, "b", "a", "input b id", Child::Right, "body $a + $"),
] {
let target = parameter(0x100, id);
add(
11,
context,
"input ",
old,
new,
(
Id::from_u128(0x100),
Some(Field::Input(a::ParameterId(Id::from_u128(id)))),
None,
TokenRole::Use(Role::ParameterDeclaration),
target,
),
);
add(
11,
"body $a + $b;",
prefix,
old,
new,
(
Id::from_u128(0x100),
Some(Field::Body),
Some(vec![edge]),
TokenRole::Use(Role::ParameterUse),
target,
),
);
}
for (context, path) in [
("@f(b: 2, a: 1)", vec![Child::Left, Child::Left]),
("@f(a: 3, b: 4)", vec![Child::Left, Child::Right]),
] {
add(
23,
context,
"@",
"f",
"g",
(
Id::from_u128(0x300),
Some(Field::Body),
Some(path.clone()),
TokenRole::Use(Role::CallTarget),
global(0x100),
),
);
for (id, old, new) in [(0x200, "a", "b"), (0x201, "b", "a")] {
let offset = context.find(&format!("{old}:")).unwrap();
add(
23,
context,
&context[..offset],
old,
new,
(
Id::from_u128(0x300),
Some(Field::Body),
Some(path.clone()),
TokenRole::Use(Role::ArgumentLabel),
parameter(0x100, id),
),
);
}
}
add(
23,
"@g(a: 5, b: 6)",
"@",
"g",
"f",
(
Id::from_u128(0x300),
Some(Field::Body),
Some(vec![Child::Right]),
TokenRole::Use(Role::CallTarget),
global(0x200),
),
);
expected.sort_by_key(|row| (row.0.file, row.0.span.start, row.0.span.end));
let actual: Vec<Row> = batch
.iter()
.map(|edit| {
let key = edit.key();
(
edit.location(),
edit.original().into(),
edit.replacement().into(),
(
key.owner,
key.field,
key.path.as_ref().map(|path| path.edges()),
key.role,
key.target,
),
)
})
.collect();
assert_eq!(actual, expected);
let noop = Composition::renames(
&index,
&BTreeMap::from([
(global(0x100), "f".into()),
(parameter(0x200, 0x200), "a".into()),
]),
&work,
)
.unwrap();
let empty = noop.original_edits(&sources).unwrap();
assert!(empty.is_empty());
assert_eq!(empty.len(), 0);
assert_eq!(empty.iter().count(), 0);
assert_eq!(work.usage, usage);
assert_eq!(work.numeric_units(), numeric);
}
#[test]
fn composition_rejects_stale_or_damaged_complete_batches_before_export() {
let mut sources = sources();
sources.insert(99, "// unrelated 雪\r\n".into());
let original = sources.clone();
let mut work = budget();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
for damage in 0..10 {
let mut current = sources.clone();
let mut composition = Composition::renames(&index, &rename_map(), &work).unwrap();
match damage {
0 => current.get_mut(&99).unwrap().push_str("changed"),
1 => {
current.remove(&23);
}
2 => {
current.insert(101, String::new());
}
3 => composition.plan.edits[0].location.span.start += 1,
4 => composition
.plan
.edits
.insert(1, composition.plan.edits[0].clone()),
5 => {
composition.plan.edits.pop();
}
6 => composition.plan.edits.swap(0, 1),
7 => {
composition
.plan
.names
.insert(global(0x100), "bad name".into());
}
8 => {
composition.plan.names.insert(global(0x100), "f".into());
}
_ => {
composition.plan.names.insert(global(999), "missing".into());
}
}
let before = current.clone();
assert!(
composition.original_edits(&current).is_err(),
"damage {damage}"
);
assert_eq!(current, before);
}
assert_eq!(sources, original);
let failure = work.frame(257, Span::default()).unwrap_err();
assert_eq!(
Composition::renames(&index, &rename_map(), &work).unwrap_err(),
failure
);
}
#[test]
fn composition_defers_only_typed_output_size_with_independent_shrink_arithmetic() {
let sources = sources();
let cap = sources.values().map(String::len).sum();
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.bytes = cap;
let mut work = Budget::new(limits).unwrap();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
assert_eq!(work.usage.bytes, cap);
let usage = work.usage;
let numeric = work.numeric_units();
let names = BTreeMap::from([(global(0x100), "ff".into())]);
assert_eq!(
Plan::renames(&index, &names, &work).unwrap_err().message,
"Source8 index/output byte limit exceeded."
);
let composition = Composition::renames(&index, &names, &work).unwrap();
let batch = composition.original_edits(&sources).unwrap();
assert_eq!(batch.len(), 3); // declaration and two distinct call targets
let growth: usize = batch
.iter()
.map(|edit| edit.replacement().len() - edit.original().len())
.sum();
assert_eq!(growth, 3);
assert_eq!(
composition.plan.apply(&sources, &work).unwrap_err().message,
"Source8 index/output byte limit exceeded."
);
// Independent future ordinary/scalar producer witness on untouched display
// name bytes. This is size/offset arithmetic only: this typed-only harness
// has no ordinary collector, mixed ownership proof or accepted output.
let start = DEFINITIONS.find("\"f a b\"").unwrap() + 1;
let shrink = Location {
file: 11,
span: Span {
start,
end: start + "f a b".len(),
},
};
assert_eq!(
&sources[&shrink.file][shrink.span.start..shrink.span.end],
"f a b"
);
for edit in batch.iter() {
let at = edit.location();
assert!(
at.file != shrink.file
|| at.span.end <= shrink.span.start
|| shrink.span.end <= at.span.start
);
}
let original_len = shrink.span.end - shrink.span.start;
assert_eq!(cap + growth - original_len + "fa".len(), cap);
assert_eq!(cap + growth - original_len + "fab".len(), cap + 1);
assert_eq!(work.usage, usage);
assert_eq!(work.numeric_units(), numeric);
}
#[test]
fn composition_keeps_name_storage_and_rename_record_limits_before_allocation() {
let sources = sources();
let cap = sources.values().map(String::len).sum();
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.bytes = cap;
let mut work = Budget::new(limits).unwrap();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
let exact = BTreeMap::from([(global(0x100), "x".repeat(cap))]);
assert_eq!(
Composition::renames(&index, &exact, &work)
.unwrap()
.original_edits(&sources)
.unwrap()
.len(),
3
);
let over = BTreeMap::from([(global(0x100), "x".repeat(cap + 1))]);
assert_eq!(
Composition::renames(&index, &over, &work)
.unwrap_err()
.message,
"Source8 index/output byte limit exceeded."
);
for invalid in [
BTreeMap::from([(global(999), "unknown".into())]),
BTreeMap::from([(global(0x100), "bad name".into())]),
BTreeMap::from([(global(0x100), "g".into())]),
BTreeMap::from([(parameter(0x100, 0x200), "b".into())]),
] {
assert!(Composition::renames(&index, &invalid, &work).is_err());
}
// A typed-empty fixture isolates the rename-map record bound without
// syntax entry usage masking it. No ordinary edits are authorized here.
let ordinary = skeleton();
let count = ordinary.identities().len();
let files = BTreeMap::from([(1, "// empty typed unit\n".into())]);
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.entries = count;
limits.references = 0;
let mut inherited = Budget::new(limits).unwrap();
let (index, _) = Index::parse(&ordinary, &files, &mut inherited).unwrap();
let mut names: BTreeMap<_, _> = index
.names()
.keys()
.enumerate()
.map(|(i, target)| (*target, format!("renamed_{i}")))
.collect();
assert_eq!(names.len(), count);
assert!(
Composition::renames(&index, &names, &inherited)
.unwrap()
.original_edits(&files)
.unwrap()
.is_empty()
);
names.insert(global(999), "additional".into());
assert_eq!(
Composition::renames(&index, &names, &inherited)
.unwrap_err()
.message,
"Source8 lowering entry limit exceeded."
);
}
#[test]
fn composition_checks_edit_record_ceiling_before_duplicate_membership() {
let sources = sources();
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.entries = 128;
limits.references = 32;
let ceiling = limits.entries + limits.references;
let mut work = Budget::new(limits).unwrap();
let (index, _) = Index::parse(&skeleton(), &sources, &mut work).unwrap();
let mut composition = Composition::renames(&index, &rename_map(), &work).unwrap();
assert_eq!(composition.original_edits(&sources).unwrap().len(), 13);
let duplicate = composition.plan.edits[0].clone();
composition.plan.edits = vec![duplicate.clone(); ceiling];
// At the exact count bound validation proceeds to the independent ownership
// error. The damaged plan never becomes a successful/authoritative batch.
assert_eq!(
composition.original_edits(&sources).unwrap_err().message,
"Forged or duplicate edit occurrence."
);
composition.plan.edits.push(duplicate);
assert_eq!(
composition.original_edits(&sources).unwrap_err().message,
"Source8 lowering occurrence limit exceeded."
);
}
#[test]
fn standalone_noop_apply_keeps_tighter_file_only_budget_and_output_boundary() {
let sources = sources();
let mut inherited = budget();
let (index, _) = Index::parse(&skeleton(), &sources, &mut inherited).unwrap();
let plan = Plan::renames(
&index,
&BTreeMap::from([(global(0x100), "f".into())]),
&inherited,
)
.unwrap();
assert_eq!(plan.edit_count(), 0);
assert!(index.names().len() > sources.len());
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.entries = sources.len();
limits.references = 0;
limits.bytes = sources.values().map(String::len).sum();
// Standalone apply checks file/edit records, not the already validated
// catalog/name-storage allocation from construction. Rechecking the catalog
// under this intentionally tighter probe budget would reject this no-op.
assert_eq!(
plan.apply(&sources, &Budget::new(limits).unwrap()).unwrap(),
sources
);
limits.bytes -= 1;
assert_eq!(
plan.apply(&sources, &Budget::new(limits).unwrap())
.unwrap_err()
.message,
"Source8 index/output byte limit exceeded."
);
}
@@ -0,0 +1,664 @@
//! Private typed canonical output. Complete semantic admission belongs to the
//! caller. No public formatter, version dispatch or source edits use this yet.
mod expression;
pub(super) use expression::{Parent, Side};
use super::{budget, lower::Field};
use crate::analysis as a;
use crate::analysis_numeric::Exact;
use crate::{Id, Kind, Layer, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
#[derive(Clone, Copy)]
pub(super) struct Limits {
pub bytes: usize,
pub entries: usize,
}
impl Limits {
pub fn source8(caller: crate::language::ParseLimits) -> Self {
let limits = budget::Limits::source8(caller);
Self {
bytes: limits.bytes,
entries: limits.entries,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Error {
pub owner: Option<Id>,
pub field: &'static str,
pub message: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) struct Usage {
pub bytes: usize,
pub entries: usize,
}
struct Sink {
buffer: Vec<u8>,
written: usize,
maximum: usize,
}
impl Sink {
fn room(&self, bytes: usize) -> std::io::Result<()> {
if bytes > self.maximum.saturating_sub(self.written) {
return Err(std::io::Error::other(
"Source8 writer output byte limit exceeded.",
));
}
Ok(())
}
}
impl Write for Sink {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.room(bytes.len())?;
self.buffer.extend_from_slice(bytes);
self.written += bytes.len();
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub(super) struct Writer<'p> {
project: &'p Project,
parameters: BTreeMap<Id, BTreeMap<a::ParameterId, &'p a::Parameter>>,
limits: Limits,
entries: usize,
sink: Sink,
owner: Option<Id>,
field: &'static str,
failed: Option<Error>,
}
impl<'p> Writer<'p> {
pub fn new(project: &'p Project, limits: Limits) -> Result<Self, Error> {
let hard = Limits::source8(crate::language::ParseLimits::UNLIMITED);
if limits.bytes > hard.bytes || limits.entries > hard.entries {
return Err(Error {
owner: None,
field: "writer",
message: "Writer limits exceed the source8 profile.".into(),
});
}
let mut writer = Self {
project,
parameters: BTreeMap::new(),
limits,
entries: 0,
sink: Sink {
buffer: Vec::new(),
written: 0,
maximum: limits.bytes,
},
owner: None,
field: "writer",
failed: None,
};
for definition in project.definitions.values() {
writer.owner = Some(definition.id);
writer.field = "parameters";
writer.entry()?;
if let a::DefinitionBody::Calculation(calculation) = &definition.body {
let mut parameters = BTreeMap::new();
let mut names = BTreeSet::new();
for parameter in &calculation.parameters {
writer.entry()?;
if parameters.contains_key(&parameter.id)
|| !names.insert(parameter.name.as_str())
{
return Err(writer.error("Duplicate scoped parameter identity or name."));
}
parameters.insert(parameter.id, parameter);
}
writer.parameters.insert(definition.id, parameters);
}
}
Ok(writer)
}
pub fn usage(&self) -> Usage {
Usage {
bytes: self.sink.written,
entries: self.entries,
}
}
fn error(&self, message: impl Into<String>) -> Error {
Error {
owner: self.owner,
field: self.field,
message: message.into(),
}
}
fn entry(&mut self) -> Result<(), Error> {
if self.entries == self.limits.entries {
return Err(self.error("Source8 writer entry limit exceeded."));
}
self.entries += 1;
Ok(())
}
fn pending(&self, count: usize) -> Result<(), Error> {
if count > self.limits.entries {
return Err(self.error("Source8 writer pending work limit exceeded."));
}
Ok(())
}
fn text(&mut self, text: &str) -> Result<(), Error> {
self.sink
.write_all(text.as_bytes())
.map_err(|cause| self.error(cause.to_string()))
}
fn quote(&mut self, text: &str) -> Result<(), Error> {
// Identical serde_json string escaping to language::quote, streamed
// directly to the checked sink instead of an unbounded scratch String.
self.sink
.room(text.len().saturating_add(2))
.map_err(|cause| self.error(cause.to_string()))?;
serde_json::to_writer(&mut self.sink, text).map_err(|cause| self.error(cause.to_string()))
}
fn name(&mut self, name: &str) -> Result<(), Error> {
self.sink
.room(name.len())
.map_err(|cause| self.error(cause.to_string()))?;
if !crate::valid_alias(name) {
return Err(self.error("Invalid alias or parameter name."));
}
self.text(name)
}
fn exact(&mut self, exact: &Exact) -> Result<(), Error> {
// At most two already-bounded 4096-bit components, never an Expr-sized
// temporary or a second parse/normalization of the stored value.
self.text(&exact.canonical_literal())
}
fn run(
&mut self,
owner: Id,
field: &'static str,
emit: impl FnOnce(&mut Self) -> Result<(), Error>,
) -> Result<String, Error> {
if let Some(error) = &self.failed {
return Err(error.clone());
}
self.owner = Some(owner);
self.field = field;
debug_assert!(self.sink.buffer.is_empty());
if let Err(error) = emit(self) {
self.sink.buffer.clear();
self.failed = Some(error.clone());
return Err(error);
}
Ok(String::from_utf8(std::mem::take(&mut self.sink.buffer))
.expect("text and JSON string writes are UTF-8"))
}
fn definition_name(
&self,
reference: a::DefinitionRef,
kind: DefinitionKind,
) -> Result<&'p str, Error> {
let definition = self
.project
.definitions
.get(&reference.local())
.ok_or_else(|| self.error("Unknown definition reference."))?;
let matches = matches!(
(&definition.body, kind),
(
a::DefinitionBody::QuantityKind(_),
DefinitionKind::QuantityKind
) | (a::DefinitionBody::Unit(_), DefinitionKind::Unit)
| (
a::DefinitionBody::Calculation(_),
DefinitionKind::Calculation
)
);
if !matches {
return Err(self.error("Wrong definition reference domain."));
}
Ok(&definition.alias)
}
fn definition_ref(
&mut self,
reference: a::DefinitionRef,
kind: DefinitionKind,
) -> Result<(), Error> {
let alias = self.definition_name(reference, kind)?;
self.text("@")?;
self.name(alias)
}
fn property_name(&self, id: Id) -> Result<&'p str, Error> {
self.project
.elements
.get(&id)
.filter(|value| value.kind() == Kind::EngineeringProperty)
.map(|value| value.alias.as_str())
.ok_or_else(|| self.error("Unknown property reference or wrong reference domain."))
}
fn subject(&mut self, subject: a::AnalysisSubject) -> Result<(), Error> {
let alias = match subject {
a::AnalysisSubject::Element(id) => self
.project
.elements
.get(&id)
.filter(|value| !value.kind().is_analysis())
.map(|value| value.alias.as_str()),
a::AnalysisSubject::Relation(id) => self
.project
.relations
.get(&id)
.map(|value| value.alias.as_str()),
}
.ok_or_else(|| self.error("Unknown architectural subject or wrong reference domain."))?;
self.text("@")?;
self.name(alias)
}
fn optional_subject(&mut self, subject: Option<a::AnalysisSubject>) -> Result<(), Error> {
if let Some(subject) = subject {
self.subject(subject)
} else {
self.text("none")
}
}
fn type_value(&mut self, value: &a::AnalysisType) -> Result<(), Error> {
self.entry()?;
let (scalar, list) = match value {
a::AnalysisType::Scalar(value) => (value, false),
a::AnalysisType::List(value) => (value, true),
};
if list {
self.text("List(")?;
}
match scalar {
a::ScalarType::Boolean => self.text("Boolean")?,
a::ScalarType::Number => self.text("Number")?,
a::ScalarType::Quantity(kind) => {
self.text("Quantity(")?;
self.definition_ref(*kind, DefinitionKind::QuantityKind)?;
self.text(")")?;
}
}
if list {
self.text(")")?;
}
Ok(())
}
pub fn value_type(&mut self, owner: Id, value: &a::AnalysisType) -> Result<String, Error> {
self.run(owner, "type", |writer| writer.type_value(value))
}
fn model_binding(
&self,
owner: Id,
field: Field,
) -> Result<(&'p a::Binding, Option<Id>), Error> {
if field == Field::Body {
if let Some(a::Definition {
body: a::DefinitionBody::Calculation(calculation),
..
}) = self.project.definitions.get(&owner)
{
return Ok((&calculation.body, Some(owner)));
}
} else if let Some(element) = self.project.elements.get(&owner) {
match (element.shared(), field) {
(Some(SharedBody::EngineeringProperty { base, .. }), Field::Base) => {
return Ok((base, None));
}
(Some(SharedBody::AnalysisCheck { predicate, .. }), Field::Predicate) => {
return Ok((predicate, None));
}
(Some(SharedBody::AnalysisCase { overrides, .. }), Field::Override(property)) => {
return overrides
.get(&property)
.map(|binding| (binding, None))
.ok_or_else(|| self.error("Missing override binding."));
}
_ => {}
}
}
Err(self.error("Binding field does not belong to the requested owner."))
}
fn binding_value(
&mut self,
binding: &a::Binding,
calculation: Option<Id>,
) -> Result<(), Error> {
match binding {
a::Binding::Unbound => {
self.entry()?;
self.text("unbound")
}
a::Binding::Expression(expression) => {
self.emit_expression(expression, calculation, Parent::Root)
}
}
}
pub fn binding(&mut self, owner: Id, field: Field) -> Result<String, Error> {
self.run(owner, binding_field(field), |writer| {
let (binding, scope) = writer.model_binding(owner, field)?;
writer.binding_value(binding, scope)
})
}
pub fn expression(
&mut self,
owner: Id,
field: Field,
expression: &a::Expr,
parent: Parent,
) -> Result<String, Error> {
self.run(owner, binding_field(field), |writer| {
let (_, scope) = writer.model_binding(owner, field)?;
writer.emit_expression(expression, scope, parent)
})
}
fn parameter_value(&mut self, calculation: Id, id: a::ParameterId) -> Result<(), Error> {
self.entry()?;
let parameter = *self
.parameters
.get(&calculation)
.and_then(|parameters| parameters.get(&id))
.ok_or_else(|| self.error("Unknown scoped parameter."))?;
self.text("input ")?;
self.name(&parameter.name)?;
self.text(" id \"")?;
self.text(&parameter.id.0.to_string())?;
self.text("\" : ")?;
self.type_value(&parameter.value_type)?;
self.text(";")
}
pub fn parameter(
&mut self,
calculation: Id,
parameter: a::ParameterId,
) -> Result<String, Error> {
self.run(calculation, "input", |writer| {
writer.parameter_value(calculation, parameter)
})
}
fn override_value(&mut self, property: Id, binding: &a::Binding) -> Result<(), Error> {
self.entry()?;
let alias = self.property_name(property)?;
self.text("@")?;
self.name(alias)?;
self.text(" = ")?;
self.binding_value(binding, None)?;
self.text(";")
}
pub fn override_entry(&mut self, case: Id, property: Id) -> Result<String, Error> {
self.run(case, "overrides", |writer| {
let (binding, _) = writer.model_binding(case, Field::Override(property))?;
writer.override_value(property, binding)
})
}
fn header(
&mut self,
word: &str,
alias: &str,
name: &str,
id: Id,
description: &str,
) -> Result<(), Error> {
self.entry()?;
self.text(" ")?;
self.text(word)?;
self.text(" ")?;
self.name(alias)?;
self.text(" ")?;
self.quote(name)?;
self.text(" id \"")?;
self.text(&id.to_string())?;
self.text("\" {\n")?;
self.start_field("description")?;
self.quote(description)?;
self.text(";\n")
}
fn start_field(&mut self, name: &'static str) -> Result<(), Error> {
self.field = name;
self.entry()?;
self.text(" ")?;
self.text(name)?;
self.text(" ")
}
pub fn definition(&mut self, id: Id) -> Result<String, Error> {
self.run(id, "definition", |writer| {
let project = writer.project;
let definition = project
.definitions
.get(&id)
.ok_or_else(|| writer.error("Unknown definition."))?;
let word = match &definition.body {
a::DefinitionBody::QuantityKind(_) => "quantity_kind",
a::DefinitionBody::Unit(_) => "unit",
a::DefinitionBody::Calculation(_) => "calculation",
};
writer.header(
word,
&definition.alias,
&definition.name,
id,
&definition.description,
)?;
match &definition.body {
a::DefinitionBody::QuantityKind(kind) => {
writer.start_field("dimension")?;
writer.text("{")?;
const AXES: [&str; 7] = [
"length",
"mass",
"time",
"electric_current",
"temperature",
"amount",
"luminous_intensity",
];
let mut nonzero = false;
for (axis, exponent) in AXES.into_iter().zip(kind.dimension.exponents()) {
if exponent != 0 {
writer.entry()?;
writer.text(" ")?;
writer.text(axis)?;
writer.text(" ")?;
writer.text(&exponent.to_string())?;
writer.text(";")?;
nonzero = true;
}
}
if nonzero {
writer.text(" ")?;
}
writer.text("};\n")?;
writer.start_field("algebra")?;
match kind.algebra {
a::QuantityAlgebra::Linear => writer.text("Linear")?,
a::QuantityAlgebra::Affine { difference } => {
writer.text("Affine(")?;
writer.definition_ref(difference, DefinitionKind::QuantityKind)?;
writer.text(")")?;
}
}
writer.text(";\n")?;
}
a::DefinitionBody::Unit(unit) => {
writer.start_field("kind")?;
writer.definition_ref(unit.kind, DefinitionKind::QuantityKind)?;
writer.text(";\n")?;
writer.start_field("scale")?;
writer.exact(&unit.scale)?;
writer.text(";\n")?;
writer.start_field("offset")?;
writer.exact(&unit.offset)?;
writer.text(";\n")?;
writer.start_field("symbol")?;
writer.quote(&unit.symbol)?;
writer.text(";\n")?;
}
a::DefinitionBody::Calculation(calculation) => {
for parameter in &calculation.parameters {
writer.field = "input";
writer.text(" ")?;
writer.parameter_value(id, parameter.id)?;
writer.text("\n")?;
}
writer.start_field("result")?;
writer.type_value(&calculation.result)?;
writer.text(";\n")?;
writer.start_field("body")?;
writer.binding_value(&calculation.body, Some(id))?;
writer.text(";\n")?;
}
}
writer.text(" }\n\n")
})
}
pub fn analysis_element(&mut self, id: Id) -> Result<String, Error> {
self.run(id, "element", |writer| {
let project = writer.project;
let element = project
.elements
.get(&id)
.ok_or_else(|| writer.error("Unknown analysis element."))?;
let word = match (element.kind(), element.layer()) {
(Kind::EngineeringProperty, Layer::Oa) => "oa_property",
(Kind::EngineeringProperty, Layer::Sa) => "sa_property",
(Kind::AnalysisCheck, Layer::Oa) => "oa_check",
(Kind::AnalysisCheck, Layer::Sa) => "sa_check",
(Kind::AnalysisCase, Layer::Oa) => "oa_analysis",
(Kind::AnalysisCase, Layer::Sa) => "sa_analysis",
_ => return Err(writer.error("Expected an analysis element.")),
};
if element.parent.is_some() {
return Err(writer.error("Analysis declarations must be roots."));
}
writer.header(
word,
&element.alias,
&element.name,
id,
&element.description,
)?;
writer.start_field("parent")?;
writer.text("none;\n")?;
match element.shared().expect("analysis kind is a shared body") {
SharedBody::EngineeringProperty {
subject,
value_type,
display_unit,
base,
} => {
writer.start_field("subject")?;
writer.subject(*subject)?;
writer.text(";\n")?;
writer.start_field("type")?;
writer.type_value(value_type)?;
writer.text(";\n")?;
writer.start_field("display_unit")?;
if let Some(unit) = display_unit {
writer.definition_ref(*unit, DefinitionKind::Unit)?;
} else {
writer.text("none")?;
}
writer.text(";\n")?;
writer.start_field("base")?;
writer.binding_value(base, None)?;
writer.text(";\n")?;
}
SharedBody::AnalysisCheck {
subject,
documented_constraint,
predicate,
} => {
writer.start_field("subject")?;
writer.optional_subject(*subject)?;
writer.text(";\n")?;
writer.start_field("documented_constraint")?;
if let Some(id) = documented_constraint {
let alias = project
.elements
.get(id)
.filter(|element| element.kind() == Kind::Constraint)
.map(|element| element.alias.as_str())
.ok_or_else(|| {
writer.error("Unknown constraint or wrong reference domain.")
})?;
writer.text("@")?;
writer.name(alias)?;
} else {
writer.text("none")?;
}
writer.text(";\n")?;
writer.start_field("predicate")?;
writer.binding_value(predicate, None)?;
writer.text(";\n")?;
}
SharedBody::AnalysisCase {
subject,
base: a::BaselineBase::ModelBaseline,
overrides,
} => {
writer.start_field("subject")?;
writer.optional_subject(*subject)?;
writer.text(";\n")?;
writer.start_field("base")?;
writer.text("ModelBaseline;\n")?;
writer.start_field("overrides")?;
if overrides.is_empty() {
writer.text("{};\n")?;
} else {
writer.text("{\n")?;
for (property, binding) in overrides {
writer.text(" ")?;
writer.override_value(*property, binding)?;
writer.text("\n")?;
}
writer.text(" };\n")?;
}
}
_ => return Err(writer.error("Expected an analysis body.")),
}
writer.text(" }\n\n")
})
}
pub fn typed_declarations(&mut self) -> Result<Vec<(Id, String)>, Error> {
if let Some(error) = &self.failed {
return Err(error.clone());
}
let project = self.project;
let mut declarations = Vec::new();
for id in project.definitions.keys() {
declarations.push((*id, self.definition(*id)?));
}
for element in project
.elements
.values()
.filter(|element| element.kind().is_analysis())
{
declarations.push((element.id, self.analysis_element(element.id)?));
}
Ok(declarations)
}
}
#[derive(Clone, Copy)]
enum DefinitionKind {
QuantityKind,
Unit,
Calculation,
}
fn binding_field(field: Field) -> &'static str {
match field {
Field::Body => "body",
Field::Base => "base",
Field::Predicate => "predicate",
Field::Override(_) => "overrides",
_ => "binding",
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,284 @@
use super::*;
#[derive(Clone, Copy)]
pub enum Side {
Left,
Right,
}
#[derive(Clone, Copy)]
pub enum Parent {
Root,
Binary(a::BinaryOp, Side),
Negate,
}
fn operator(op: a::BinaryOp) -> (&'static str, u8) {
use a::BinaryOp::*;
match op {
Or => (" or ", 1),
And => (" and ", 2),
Equal => (" == ", 3),
NotEqual => (" != ", 3),
Less => (" < ", 4),
LessOrEqual => (" <= ", 4),
Greater => (" > ", 4),
GreaterOrEqual => (" >= ", 4),
Add => (" + ", 5),
Subtract => (" - ", 5),
Multiply => (" * ", 6),
Divide => (" / ", 6),
}
}
fn comparison(op: a::BinaryOp) -> bool {
matches!(operator(op).1, 3 | 4)
}
fn group(expression: &a::Expr, parent: Parent) -> bool {
let a::Expr::Binary { op, .. } = expression else {
return false;
};
match parent {
Parent::Root => false,
Parent::Negate => true,
Parent::Binary(parent, side) => {
(comparison(*op) && comparison(parent))
|| operator(*op).1 < operator(parent).1
|| (operator(*op).1 == operator(parent).1 && matches!(side, Side::Right))
}
}
}
#[derive(Clone, Copy)]
enum Task<'a> {
Expression(&'a a::Expr, Parent, usize),
Text(&'static str),
Alias(&'a str),
Literal(&'a Exact),
Exponent(i8),
}
fn schedule<'a>(
writer: &Writer<'_>,
tasks: &mut Vec<Task<'a>>,
ordered: &[Task<'a>],
) -> Result<(), Error> {
writer.pending(tasks.len().saturating_add(ordered.len()))?;
tasks.extend(ordered.iter().rev().copied());
Ok(())
}
impl Writer<'_> {
pub(super) fn emit_expression(
&mut self,
expression: &a::Expr,
calculation: Option<Id>,
parent: Parent,
) -> Result<(), Error> {
self.pending(1)?;
let mut tasks = vec![Task::Expression(expression, parent, 1)];
while let Some(task) = tasks.pop() {
match task {
Task::Text(text) => self.text(text)?,
Task::Alias(name) => {
self.entry()?;
self.name(name)?;
}
Task::Literal(exact) => self.exact(exact)?,
Task::Exponent(exponent) => self.text(&exponent.to_string())?,
Task::Expression(expression, parent, depth) => {
self.entry()?;
if depth > a::MAX_EXPRESSION_DEPTH {
return Err(self.error("Expression depth exceeds128."));
}
if group(expression, parent) {
self.text("(")?;
schedule(self, &mut tasks, &[Task::Text(")")])?;
}
use Task::{Alias, Exponent, Expression as Child, Literal, Text};
use a::Expr as E;
match expression {
E::Boolean(value) => self.text(if *value { "true" } else { "false" })?,
E::Number(value) => self.exact(value)?,
E::Quantity { magnitude, unit } => {
let alias = self.definition_name(*unit, DefinitionKind::Unit)?;
schedule(
self,
&mut tasks,
&[Literal(magnitude), Text(" [@"), Alias(alias), Text("]")],
)?;
}
E::Property(id) => {
let alias = self.property_name(*id)?;
schedule(self, &mut tasks, &[Text("@"), Alias(alias)])?;
}
E::Parameter(parameter) => {
let calculation = calculation.ok_or_else(|| {
self.error("Parameter use requires an owning calculation.")
})?;
let parameter = *self
.parameters
.get(&calculation)
.and_then(|parameters| parameters.get(parameter))
.ok_or_else(|| {
self.error("Unknown parameter in owning calculation.")
})?;
schedule(self, &mut tasks, &[Text("$"), Alias(&parameter.name)])?;
}
E::List(items) => {
if items.len() > a::MAX_LIST_LENGTH {
return Err(self.error("List exceeds1024 items."));
}
self.pending(
tasks
.len()
.saturating_add(items.len().saturating_mul(2))
.saturating_add(1),
)?;
self.text("[")?;
tasks.push(Text("]"));
for (index, item) in items.iter().enumerate().rev() {
tasks.push(Child(item, Parent::Root, depth + 1));
if index != 0 {
tasks.push(Text(", "));
}
}
}
E::Unary { op, operand } => match op {
a::UnaryOp::Negate => schedule(
self,
&mut tasks,
&[Text("- "), Child(operand, Parent::Negate, depth + 1)],
)?,
a::UnaryOp::Absolute | a::UnaryOp::Not => schedule(
self,
&mut tasks,
&[
Text(if *op == a::UnaryOp::Absolute {
"abs("
} else {
"not("
}),
Child(operand, Parent::Root, depth + 1),
Text(")"),
],
)?,
},
E::Binary { op, left, right } => schedule(
self,
&mut tasks,
&[
Child(left, Parent::Binary(*op, Side::Left), depth + 1),
Text(operator(*op).0),
Child(right, Parent::Binary(*op, Side::Right), depth + 1),
],
)?,
E::If {
condition,
when_true,
when_false,
} => schedule(
self,
&mut tasks,
&[
Text("if("),
Child(condition, Parent::Root, depth + 1),
Text(", "),
Child(when_true, Parent::Root, depth + 1),
Text(", "),
Child(when_false, Parent::Root, depth + 1),
Text(")"),
],
)?,
E::Call {
definition,
arguments,
} => {
let alias =
self.definition_name(*definition, DefinitionKind::Calculation)?;
let project = self.project;
let a::DefinitionBody::Calculation(callee) =
&project.definitions[&definition.local()].body
else {
unreachable!("checked calculation domain")
};
if arguments.len() != callee.parameters.len()
|| callee
.parameters
.iter()
.any(|parameter| !arguments.contains_key(&parameter.id))
{
return Err(self.error(
"Call argument IDs must match the called signature exactly.",
));
}
self.pending(
tasks
.len()
.saturating_add(arguments.len().saturating_mul(4))
.saturating_add(1),
)?;
self.entry()?;
self.text("@")?;
self.name(alias)?;
self.text("(")?;
tasks.push(Text(")"));
for (index, parameter) in callee.parameters.iter().enumerate().rev() {
tasks.push(Child(
&arguments[&parameter.id],
Parent::Root,
depth + 1,
));
tasks.push(Text(": "));
tasks.push(Alias(&parameter.name));
if index != 0 {
tasks.push(Text(", "));
}
}
}
E::Sum { values, initial } => schedule(
self,
&mut tasks,
&[
Text("sum("),
Child(values, Parent::Root, depth + 1),
Text(", "),
Child(initial, Parent::Root, depth + 1),
Text(")"),
],
)?,
E::Power { base, exponent } => {
if !(-16..=16).contains(exponent) {
return Err(self.error("Power exponent exceeds -16..16."));
}
schedule(
self,
&mut tasks,
&[
Text("power("),
Child(base, Parent::Root, depth + 1),
Text(", "),
Exponent(*exponent),
Text(")"),
],
)?;
}
E::AsKind { kind, value } => {
let alias =
self.definition_name(*kind, DefinitionKind::QuantityKind)?;
schedule(
self,
&mut tasks,
&[
Text("as_kind(@"),
Alias(alias),
Text(", "),
Child(value, Parent::Root, depth + 1),
Text(")"),
],
)?;
}
}
}
}
}
Ok(())
}
}
@@ -0,0 +1,910 @@
use super::super::{cursor::Cursor, declarations, lex, lower, model_oracle};
use super::*;
use crate::language::ParseLimits;
use a::{
AnalysisType, BinaryOp, Binding, DefinitionBody, DefinitionRef, Expr, ParameterId, ScalarType,
UnaryOp,
};
fn limits() -> Limits {
Limits::source8(ParseLimits::UNLIMITED)
}
fn writer(project: &Project) -> Writer<'_> {
Writer::new(project, limits()).unwrap()
}
fn id(text: &str) -> Id {
Id::parse_str(text).unwrap()
}
fn object(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn number(value: i64) -> Expr {
Expr::Number(Exact::from_integer(value))
}
fn unary(op: UnaryOp, value: Expr) -> Expr {
Expr::Unary {
op,
operand: Box::new(value),
}
}
fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr {
Expr::Binary {
op,
left: Box::new(left),
right: Box::new(right),
}
}
fn quantity(value: i64, unit: Id) -> Expr {
Expr::Quantity {
magnitude: Exact::from_integer(value),
unit: DefinitionRef::Local(unit),
}
}
fn budget() -> budget::Budget {
budget::Budget::new(budget::Limits::source8(ParseLimits::UNLIMITED)).unwrap()
}
fn unit(source: &str, budget: &mut budget::Budget) -> Result<lower::Unit, super::super::Error> {
let tokens = lex::tokens(source, budget)?;
let mut cursor = Cursor::new(source, &tokens, budget);
let mut parsed = Vec::new();
while cursor.cursor < tokens.len() {
parsed.push(declarations::parse(&mut cursor, 8, None)?);
}
Ok(lower::Unit {
file: 1,
declarations: parsed,
})
}
fn merge(mut project: Project, lowered: lower::Lowered) -> Project {
project.definitions.extend(lowered.definitions);
project.elements.extend(lowered.elements);
project
}
fn parse_typed(project: Project, source: &str) -> Project {
let mut budget = budget();
let unit = unit(source, &mut budget).unwrap();
let lowered = lower::lower(&project, &[unit], &budget).unwrap();
merge(project, lowered)
}
fn skeleton(project: &Project) -> Project {
let mut project = project.clone();
project.definitions.clear();
project
.elements
.retain(|_, element| !element.kind().is_analysis());
project
}
fn canonical(project: &Project) -> String {
writer(project)
.typed_declarations()
.unwrap()
.into_iter()
.map(|(_, text)| text)
.collect()
}
fn read_expression(project: &Project, source: &str) -> Expr {
// A staged syntax/lowering wrapper, not a whole public source8 parser.
let source = format!(
r#"calculation writer_probe "Probe" id "00000000-0000-0000-0000-000000000099" {{
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
result Number; body {source};
}}"#
);
let mut parsed = parse_typed(project.clone(), &source);
let definition = parsed.definitions.remove(&Id::from_u128(0x99)).unwrap();
let DefinitionBody::Calculation(calculation) = definition.body else {
panic!("calculation")
};
let Binding::Expression(expression) = calculation.body else {
panic!("expression")
};
expression
}
#[test]
fn typed_oa_sa_canonical_roundtrips_match_independent_projects() {
for (layer, ordinary_source) in [
(
Layer::Oa,
include_str!("../lower/fixtures/ordinary-oa.arcadia"),
),
(
Layer::Sa,
include_str!("../lower/fixtures/ordinary-sa.arcadia"),
),
] {
let expected = model_oracle::project(layer);
for (alias, name) in model_oracle::NAMES {
let id = object(&expected, alias);
assert_eq!(
expected
.definitions
.get(&id)
.map(|value| value.name.as_str())
.or_else(|| expected.elements.get(&id).map(|value| value.name.as_str()))
.unwrap(),
name
);
}
let text = canonical(&expected);
let actual = parse_typed(crate::language::parse(ordinary_source).unwrap(), &text);
assert_eq!(actual, expected);
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
assert_eq!(canonical(&actual), text);
assert!(
!text.contains("engineering_property")
&& !text.contains("analysis_case")
&& !text.contains("analysis_check")
);
let prefix = if layer == Layer::Oa { "oa" } else { "sa" };
for word in ["property", "check", "analysis"] {
assert!(text.contains(&format!("{prefix}_{word} ")));
}
}
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
}
#[test]
fn typed_declaration_order_and_required_empty_values_are_canonical() {
let mut project = model_oracle::project(Layer::Oa);
let duration = object(&project, "duration");
let total = object(&project, "total_time");
if let Some(SharedBody::EngineeringProperty { display_unit, .. }) =
project.elements.get_mut(&total).unwrap().body.shared_mut()
{
*display_unit = None;
}
let empty_case = object(&project, "unmeasured");
if let Some(SharedBody::AnalysisCase { overrides, .. }) = project
.elements
.get_mut(&empty_case)
.unwrap()
.body
.shared_mut()
{
overrides.clear();
}
let degraded = object(&project, "degraded");
let dispatch = object(&project, "dispatch_time");
let second = object(&project, "second");
if let Some(SharedBody::AnalysisCase { overrides, .. }) = project
.elements
.get_mut(&degraded)
.unwrap()
.body
.shared_mut()
{
overrides.insert(dispatch, Binding::Expression(quantity(1, second)));
}
let definitions = [
(
100,
"dimensionless",
DefinitionBody::QuantityKind(a::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::ZERO,
algebra: a::QuantityAlgebra::Linear,
}),
),
(
101,
"point",
DefinitionBody::QuantityKind(a::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
algebra: a::QuantityAlgebra::Affine {
difference: DefinitionRef::Local(duration),
},
}),
),
(
102,
"point_unit",
DefinitionBody::Unit(a::UnitDefinition {
kind: DefinitionRef::Local(Id::from_u128(101)),
scale: Exact::from_integer(1),
offset: Exact::from_integer(-2),
symbol: "\"café\"\n雪".into(),
}),
),
(
103,
"unused",
DefinitionBody::Calculation(a::CalculationDefinition {
parameters: Vec::new(),
result: AnalysisType::List(ScalarType::Number),
body: Binding::Unbound,
}),
),
];
for (id, alias, body) in definitions {
let id = Id::from_u128(id);
project.definitions.insert(
id,
a::Definition {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
body,
},
);
}
let mut writer = writer(&project);
let declarations = writer.typed_declarations().unwrap();
let ids: Vec<_> = declarations.iter().map(|(id, _)| *id).collect();
let expected_ids: Vec<_> = project
.definitions
.keys()
.copied()
.chain(
project
.elements
.values()
.filter(|element| element.kind().is_analysis())
.map(|element| element.id),
)
.collect();
assert_eq!(ids, expected_ids);
let text: String = declarations.into_iter().map(|(_, text)| text).collect();
assert!(text.find("@receive_time =").unwrap() < text.find("@dispatch_time =").unwrap());
for expected in [
"dimension {};",
"dimension { time 1; };",
"algebra Affine(@duration);",
"offset ratio(-2, 1);",
"result List(Number);",
"body unbound;",
"display_unit none;",
"overrides {};",
"parent none;",
"subject none;",
"documented_constraint none;",
] {
assert!(text.contains(expected), "{expected}");
}
assert_eq!(
text.matches("\" id \"").count(),
project.definitions.len() + 7
);
let actual = parse_typed(skeleton(&project), &text);
assert_eq!(actual, project);
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
assert_eq!(canonical(&actual), text);
assert_eq!(
self::writer(&project)
.override_entry(empty_case, total)
.unwrap_err()
.field,
"overrides"
);
}
#[test]
fn all_expr_variants_and_precedence_preserve_exact_trees() {
let mut project = model_oracle::project(Layer::Oa);
let second = object(&project, "second");
let duration = object(&project, "duration");
let property = object(&project, "receive_time");
let total = object(&project, "total_time");
let combine = object(&project, "combine");
let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let b = ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
let numeric = AnalysisType::Scalar(ScalarType::Number);
let boolean = AnalysisType::Scalar(ScalarType::Boolean);
let duration_type = AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(duration)));
let cases = [
(Expr::Boolean(true), "true", boolean.clone()),
(number(-1), "ratio(-1, 1)", numeric.clone()),
(
quantity(-1, second),
"ratio(-1, 1) [@second]",
duration_type.clone(),
),
(
Expr::Property(property),
"@receive_time",
duration_type.clone(),
),
(Expr::Parameter(a), "$a", duration_type.clone()),
(
Expr::List(vec![number(1), number(2)]),
"[1, 2]",
AnalysisType::List(ScalarType::Number),
),
(
unary(UnaryOp::Absolute, unary(UnaryOp::Negate, number(2))),
"abs(- 2)",
numeric.clone(),
),
(
binary(BinaryOp::Add, number(1), number(2)),
"1 + 2",
numeric.clone(),
),
(
Expr::If {
condition: Box::new(Expr::Boolean(true)),
when_true: Box::new(number(1)),
when_false: Box::new(number(2)),
},
"if(true, 1, 2)",
numeric.clone(),
),
(
Expr::Call {
definition: DefinitionRef::Local(combine),
arguments: BTreeMap::from([(b, quantity(2, second)), (a, quantity(1, second))]),
},
"@combine(a: 1 [@second], b: 2 [@second])",
duration_type.clone(),
),
(
Expr::Sum {
values: Box::new(Expr::List(vec![number(1), number(2)])),
initial: Box::new(number(0)),
},
"sum([1, 2], 0)",
numeric.clone(),
),
(
Expr::Power {
base: Box::new(number(2)),
exponent: -2,
},
"power(2, -2)",
numeric.clone(),
),
(
Expr::AsKind {
kind: DefinitionRef::Local(duration),
value: Box::new(binary(
BinaryOp::Divide,
Expr::Power {
base: Box::new(quantity(1, second)),
exponent: 2,
},
quantity(1, second),
)),
},
"as_kind(@duration, power(1 [@second], 2) / 1 [@second])",
duration_type,
),
];
project.elements.remove(&object(&project, "budget"));
for (expected, golden, value_type) in cases {
let parameter = matches!(expected, Expr::Parameter(_));
let owner = if parameter { combine } else { total };
let field = if parameter { Field::Body } else { Field::Base };
let text = writer(&project)
.expression(owner, field, &expected, Parent::Root)
.unwrap();
assert_eq!(text, golden);
let actual = read_expression(&project, &text);
assert_eq!(actual, expected);
let mut candidate = project.clone();
if parameter {
let DefinitionBody::Calculation(calculation) =
&mut candidate.definitions.get_mut(&combine).unwrap().body
else {
panic!()
};
calculation.body = Binding::Expression(actual);
} else {
let Some(SharedBody::EngineeringProperty {
base,
value_type: ty,
display_unit,
..
}) = candidate
.elements
.get_mut(&total)
.unwrap()
.body
.shared_mut()
else {
panic!()
};
*base = Binding::Expression(actual);
*ty = value_type;
*display_unit = None;
}
a::admit(&candidate, a::AdmissionLimits::REFERENCE).unwrap();
}
let cases = [
(unary(UnaryOp::Negate, number(1)), "- 1"),
(unary(UnaryOp::Negate, number(-1)), "- ratio(-1, 1)"),
(
unary(UnaryOp::Negate, unary(UnaryOp::Negate, number(1))),
"- - 1",
),
(
unary(UnaryOp::Negate, binary(BinaryOp::Add, number(1), number(2))),
"- (1 + 2)",
),
(unary(UnaryOp::Not, Expr::Boolean(true)), "not(true)"),
(
binary(
BinaryOp::Subtract,
number(1),
binary(BinaryOp::Subtract, number(2), number(3)),
),
"1 - (2 - 3)",
),
(
binary(
BinaryOp::Subtract,
binary(BinaryOp::Subtract, number(1), number(2)),
number(3),
),
"1 - 2 - 3",
),
(
binary(
BinaryOp::Add,
number(1),
binary(BinaryOp::Add, number(2), number(3)),
),
"1 + (2 + 3)",
),
(
binary(
BinaryOp::Multiply,
binary(BinaryOp::Add, number(1), number(2)),
number(3),
),
"(1 + 2) * 3",
),
(
binary(
BinaryOp::Equal,
binary(BinaryOp::Less, number(1), number(2)),
Expr::Boolean(true),
),
"(1 < 2) == true",
),
(
binary(
BinaryOp::Equal,
binary(BinaryOp::Equal, Expr::Boolean(true), Expr::Boolean(false)),
Expr::Boolean(true),
),
"(true == false) == true",
),
(
binary(
BinaryOp::Equal,
Expr::Boolean(true),
binary(BinaryOp::Less, number(1), number(2)),
),
"true == (1 < 2)",
),
(Expr::List(Vec::new()), "[]"),
];
for (expected, golden) in cases {
let text = writer(&project)
.expression(total, Field::Base, &expected, Parent::Root)
.unwrap();
assert_eq!(text, golden);
assert_eq!(read_expression(&project, &text), expected);
}
}
#[test]
fn canonical_calls_use_callee_parameter_order_and_qualified_names() {
let mut project = model_oracle::project(Layer::Oa);
let combine = object(&project, "combine");
let total = object(&project, "total_time");
let duration = object(&project, "duration");
let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let b = ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
let DefinitionBody::Calculation(calculation) =
&mut project.definitions.get_mut(&combine).unwrap().body
else {
panic!()
};
calculation.parameters.reverse();
let other = Id::from_u128(120);
project.definitions.insert(
other,
a::Definition {
id: other,
alias: "other".into(),
name: "Other".into(),
description: String::new(),
body: DefinitionBody::Calculation(a::CalculationDefinition {
parameters: vec![a::Parameter {
id: a,
name: "first".into(),
value_type: AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(
duration,
))),
}],
result: AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(duration))),
body: Binding::Expression(Expr::Parameter(a)),
}),
},
);
project.definitions.insert(
a.0,
a::Definition {
id: a.0,
alias: "collision".into(),
name: "Collision".into(),
description: String::new(),
body: DefinitionBody::QuantityKind(a::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::ZERO,
algebra: a::QuantityAlgebra::Linear,
}),
},
);
let mut out = writer(&project);
let text = out.binding(total, Field::Base).unwrap();
assert!(text.starts_with("@combine(b: @dispatch_time, a: @receive_time)"));
let call = Expr::Call {
definition: DefinitionRef::Local(other),
arguments: BTreeMap::from([(a, Expr::Parameter(a))]),
};
assert_eq!(
out.expression(combine, Field::Body, &call, Parent::Root)
.unwrap(),
"@other(first: $a)"
);
assert!(out.parameter(combine, a).unwrap().starts_with("input a id"));
assert!(
out.parameter(other, a)
.unwrap()
.starts_with("input first id")
);
assert!(out.parameter(combine, b).unwrap().starts_with("input b id"));
let text = canonical(&project);
let actual = parse_typed(skeleton(&project), &text);
assert_eq!(actual, project);
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
}
#[test]
fn subtree_output_respects_parent_context_and_retained_delimiters() {
let project = model_oracle::project(Layer::Oa);
let owner = object(&project, "total_time");
for replacement in [
binary(BinaryOp::Add, number(1), number(2)),
number(-1),
unary(UnaryOp::Negate, number(1)),
] {
let text = writer(&project)
.expression(owner, Field::Base, &replacement, Parent::Negate)
.unwrap();
let source = format!("-{text}"); // replace the entire original operand span `(1)` in `-(1)`
assert_eq!(
read_expression(&project, &source),
unary(UnaryOp::Negate, replacement)
);
}
let value = binary(BinaryOp::Subtract, number(2), number(3));
let text = writer(&project)
.expression(
owner,
Field::Base,
&value,
Parent::Binary(BinaryOp::Subtract, Side::Right),
)
.unwrap();
assert_eq!(text, "(2 - 3)");
assert_eq!(
read_expression(&project, &format!("1 - {text}")),
binary(BinaryOp::Subtract, number(1), value)
);
assert_eq!(
writer(&project)
.value_type(owner, &AnalysisType::List(ScalarType::Boolean))
.unwrap(),
"List(Boolean)"
);
let case = object(&project, "unmeasured");
let property = object(&project, "receive_time");
assert_eq!(
writer(&project).override_entry(case, property).unwrap(),
"@receive_time = unbound;"
);
}
#[test]
fn canonical_strings_and_exact_literals_use_existing_encodings() {
let mut project = model_oracle::project(Layer::Oa);
let second = object(&project, "second");
let text = "\"\\\n\r\t\u{0000} café 雪";
project.definitions.get_mut(&second).unwrap().name = text.into();
project.definitions.get_mut(&second).unwrap().description = text.into();
let DefinitionBody::Unit(unit) = &mut project.definitions.get_mut(&second).unwrap().body else {
panic!()
};
unit.symbol = text.into();
let output = writer(&project).definition(second).unwrap();
assert_eq!(output.matches(&crate::language::quote(text)).count(), 3);
let actual = parse_typed(skeleton(&project), &canonical(&project));
assert_eq!(actual, project);
let owner = object(&project, "total_time");
for source in ["0", "ratio(-1, 1)", "ratio(3, 2)", "1e100"] {
let expression = read_expression(&project, source);
let Expr::Number(value) = &expression else {
panic!()
};
assert_eq!(
writer(&project)
.expression(owner, Field::Base, &expression, Parent::Root)
.unwrap(),
value.canonical_literal()
);
}
}
#[test]
fn writer_limits_are_aggregate_exact_and_latched_without_partial_results() {
let project = model_oracle::project(Layer::Oa);
let owner = object(&project, "total_time");
let mut one = Writer::new(
&project,
Limits {
bytes: 1,
..limits()
},
)
.unwrap();
assert_eq!(
one.expression(owner, Field::Base, &number(1), Parent::Root)
.unwrap(),
"1"
);
let failure = one
.expression(owner, Field::Base, &number(2), Parent::Root)
.unwrap_err();
assert_eq!(
failure,
one.expression(owner, Field::Base, &number(0), Parent::Root)
.unwrap_err()
);
assert_eq!(one.usage().bytes, 1);
let mut measured = writer(&project);
measured
.expression(owner, Field::Base, &number(1), Parent::Root)
.unwrap();
let usage = measured.usage();
for (maximum, pass) in [(usage.entries, true), (usage.entries - 1, false)] {
let mut bounded = Writer::new(
&project,
Limits {
entries: maximum,
..limits()
},
)
.unwrap();
assert_eq!(
bounded
.expression(owner, Field::Base, &number(1), Parent::Root)
.is_ok(),
pass
);
}
let output = canonical(&project);
let mut exact = Writer::new(
&project,
Limits {
bytes: output.len(),
..limits()
},
)
.unwrap();
assert_eq!(exact.typed_declarations().unwrap().len(), 11);
let mut short = Writer::new(
&project,
Limits {
bytes: output.len() - 1,
..limits()
},
)
.unwrap();
assert!(short.typed_declarations().is_err());
assert_eq!(canonical(&project), output);
let mut quoted = project.clone();
let unit_id = object(&quoted, "second");
quoted.definitions.get_mut(&unit_id).unwrap().name = "雪\n\"".into();
let text = writer(&quoted).definition(unit_id).unwrap();
for (maximum, pass) in [(text.len(), true), (text.len() - 1, false)] {
assert_eq!(
Writer::new(
&quoted,
Limits {
bytes: maximum,
..limits()
}
)
.unwrap()
.definition(unit_id)
.is_ok(),
pass
);
}
assert!(
Writer::new(
&project,
Limits {
bytes: usize::MAX,
..limits()
}
)
.is_err()
);
}
#[test]
fn writer_lookup_scope_shape_and_depth_failures_are_model_located() {
let project = model_oracle::project(Layer::Oa);
let owner = object(&project, "total_time");
let combine = object(&project, "combine");
let duration = object(&project, "duration");
let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let cases = [
Expr::Property(duration),
Expr::Property(Id::nil()),
Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: DefinitionRef::Local(duration),
},
Expr::Parameter(a),
Expr::Call {
definition: DefinitionRef::Local(duration),
arguments: BTreeMap::new(),
},
Expr::Call {
definition: DefinitionRef::Local(combine),
arguments: BTreeMap::new(),
},
Expr::Call {
definition: DefinitionRef::Local(combine),
arguments: BTreeMap::from([(ParameterId(Id::nil()), number(1)), (a, number(2))]),
},
Expr::Power {
base: Box::new(number(1)),
exponent: 17,
},
Expr::List((0..1025).map(|_| number(1)).collect()),
];
for expression in cases {
let error = writer(&project)
.expression(owner, Field::Base, &expression, Parent::Root)
.unwrap_err();
assert_eq!(error.owner, Some(owner));
assert_eq!(error.field, "base");
}
assert!(
writer(&project)
.expression(
combine,
Field::Body,
&Expr::Parameter(ParameterId(Id::nil())),
Parent::Root
)
.is_err()
);
assert!(writer(&project).binding(owner, Field::Body).is_err());
assert!(
writer(&project)
.analysis_element(object(&project, "respond"))
.is_err()
);
let mut deep = number(-1);
for _ in 0..128 {
deep = unary(UnaryOp::Negate, deep);
}
assert!(
writer(&project)
.expression(owner, Field::Base, &deep, Parent::Root)
.unwrap_err()
.message
.contains("depth")
);
}
#[test]
fn negate_override_roundtrip_preserves_depth128_within_frame256() {
let original = model_oracle::project(Layer::Oa);
let prefix = format!("{}ratio(-1, 1)", "- ".repeat(127));
let grouped = format!("{}ratio(-1, 1){}", "-(".repeat(127), ")".repeat(127));
let property = r#"oa_property measure "Measure" id "00000000-0000-0000-0000-000000000130" { subject @respond; type Number; base unbound; }"#;
let case = |expression: &str| {
format!(
r#"oa_analysis measured "Measured" id "00000000-0000-0000-0000-000000000131" {{ base ModelBaseline; overrides {{ @measure = {expression}; }}; }}"#
)
};
let calculation = |expression: &str| {
format!(
r#"calculation measured_calc "Measured calculation" id "00000000-0000-0000-0000-000000000132" {{ result Number; body {expression}; }}"#
)
};
for (source, frames) in [
(case(&prefix), 130),
(calculation(&prefix), 129),
(calculation(&grouped), 256),
] {
let mut budget = budget();
unit(&source, &mut budget).unwrap();
assert_eq!(budget.usage.deepest_frame, frames);
}
let mut failed_budget = budget();
let error = unit(&case(&grouped), &mut failed_budget).err().unwrap();
assert!(error.message.contains("syntax frames"));
assert_eq!(failed_budget.usage.deepest_frame, 256);
let project = parse_typed(original, &format!("{property}\n{}", case(&prefix)));
a::admit(&project, a::AdmissionLimits::REFERENCE).unwrap();
let case_id = Id::from_u128(0x131);
let property_id = Id::from_u128(0x130);
let Some(SharedBody::AnalysisCase { overrides, .. }) = project.elements[&case_id].shared()
else {
panic!()
};
let Binding::Expression(root) = &overrides[&property_id] else {
panic!()
};
let mut current = root;
let mut negates = 0;
while let Expr::Unary {
op: UnaryOp::Negate,
operand,
} = current
{
current = operand;
negates += 1;
}
assert_eq!(negates, 127);
assert_eq!(current, &number(-1));
let text = writer(&project).analysis_element(case_id).unwrap();
assert!(text.contains(&prefix));
let mut budget = budget();
unit(&text, &mut budget).unwrap();
assert_eq!(budget.usage.deepest_frame, 130);
let mut before_case = project.clone();
before_case.elements.remove(&case_id);
let actual = parse_typed(before_case, &text);
assert_eq!(actual, project);
assert_eq!(writer(&actual).analysis_element(case_id).unwrap(), text);
}
#[test]
fn iterative_writer_preserves_width1024_and_empty_call_topology() {
let mut project = model_oracle::project(Layer::Oa);
let owner = object(&project, "total_time");
let expected = Expr::List((0..1024).map(|_| number(1)).collect());
let text = writer(&project)
.expression(owner, Field::Base, &expected, Parent::Root)
.unwrap();
assert_eq!(read_expression(&project, &text), expected);
let empty = Id::from_u128(140);
project.definitions.insert(
empty,
a::Definition {
id: empty,
alias: "empty".into(),
name: "Empty".into(),
description: String::new(),
body: DefinitionBody::Calculation(a::CalculationDefinition {
parameters: Vec::new(),
result: AnalysisType::Scalar(ScalarType::Number),
body: Binding::Unbound,
}),
},
);
let call = Expr::Call {
definition: DefinitionRef::Local(empty),
arguments: BTreeMap::new(),
};
let text = writer(&project)
.expression(owner, Field::Base, &call, Parent::Root)
.unwrap();
assert_eq!(text, "@empty()");
assert_eq!(read_expression(&project, &text), call);
}
+347
View File
@@ -0,0 +1,347 @@
//! One raw lexical scan for legacy adapters and staged typed source syntax.
//! Numbers remain source spans; only consumers choose a numeric domain.
use super::Span;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Class {
Word,
Number,
String,
Punctuation,
Whitespace,
Comment,
Error,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Token {
pub class: Class,
pub span: Span,
/// Entire token belongs to the old greedy numeric-token alphabet.
pub legacy_number_part: bool,
}
pub(super) struct Scanner<'a> {
source: &'a str,
cursor: usize,
}
impl<'a> Scanner<'a> {
pub fn new(source: &'a str) -> Self {
Self { source, cursor: 0 }
}
}
impl Iterator for Scanner<'_> {
type Item = Token;
fn next(&mut self) -> Option<Token> {
let source = self.source;
let bytes = source.as_bytes();
let start = self.cursor;
let first = *bytes.get(start)?;
let mut legacy_number_part = false;
let class = if matches!(first, b' ' | b'\t' | b'\r' | b'\n') {
self.cursor += 1;
while bytes
.get(self.cursor)
.is_some_and(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
{
self.cursor += 1;
}
Class::Whitespace
} else if bytes[start..].starts_with(b"//") {
self.cursor += 2;
while bytes.get(self.cursor).is_some_and(|b| *b != b'\n') {
self.cursor += 1;
}
Class::Comment
} else if first.is_ascii_alphabetic() || first == b'_' {
self.cursor += 1;
while bytes
.get(self.cursor)
.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_')
{
self.cursor += 1;
}
legacy_number_part = true;
Class::Word
} else if first.is_ascii_digit() {
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
self.cursor += 1;
}
let mut valid = true;
if bytes.get(self.cursor) == Some(&b'.') {
self.cursor += 1;
let fraction = self.cursor;
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
self.cursor += 1;
}
valid &= self.cursor != fraction;
}
if matches!(bytes.get(self.cursor), Some(b'e' | b'E')) {
self.cursor += 1;
if matches!(bytes.get(self.cursor), Some(b'+' | b'-')) {
self.cursor += 1;
}
let exponent = self.cursor;
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
self.cursor += 1;
}
valid &= self.cursor != exponent;
}
if bytes
.get(self.cursor)
.is_some_and(|b| b.is_ascii_alphabetic() || matches!(b, b'_' | b'.'))
{
valid = false;
while bytes
.get(self.cursor)
.is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.'))
{
self.cursor += 1;
}
}
legacy_number_part = true;
if valid { Class::Number } else { Class::Error }
} else if first == b'"' {
self.cursor += 1;
let mut escaped = false;
let mut closed = false;
while self.cursor < bytes.len() {
let ch = source[self.cursor..]
.chars()
.next()
.expect("UTF-8 boundary");
self.cursor += ch.len_utf8();
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
closed = true;
break;
}
}
if closed && serde_json::from_str::<String>(&source[start..self.cursor]).is_ok() {
Class::String
} else {
Class::Error
}
} else if b"{}();,[]:=@$+-*/<>!".contains(&first) {
self.cursor += 1;
if matches!(first, b'<' | b'>' | b'=' | b'!') && bytes.get(self.cursor) == Some(&b'=') {
self.cursor += 1;
}
legacy_number_part = matches!(first, b'+' | b'-');
Class::Punctuation
} else {
self.cursor += source[start..]
.chars()
.next()
.expect("UTF-8 boundary")
.len_utf8();
legacy_number_part = first == b'.';
Class::Error
};
Some(Token {
class,
span: Span {
start,
end: self.cursor,
},
legacy_number_part,
})
}
}
/// Preserve the existing public token shapes, including greedy signed JSON
/// numbers and single-character rejection of newer expression operators.
pub(super) fn legacy(source: &str) -> Vec<super::TokenSpan> {
use super::{TokenClass, TokenSpan};
let mut scanner = Scanner::new(source).peekable();
let mut out = Vec::new();
while let Some(token) = scanner.next() {
let mut span = token.span;
let first = source.as_bytes()[span.start];
if first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.') {
while scanner
.peek()
.is_some_and(|next| next.legacy_number_part && next.span.start == span.end)
{
span.end = scanner.next().expect("peeked token").span.end;
}
let class = if serde_json::from_str::<serde_json::Number>(&source[span.start..span.end])
.is_ok()
{
TokenClass::Number
} else {
TokenClass::Error
};
out.push(TokenSpan { class, span });
continue;
}
let class = match token.class {
Class::Word => {
let word = &source[span.start..span.end];
if super::reserved(word) || matches!(word, "true" | "false" | "none") {
TokenClass::Keyword
} else {
TokenClass::Identifier
}
}
Class::String => TokenClass::String,
Class::Whitespace => TokenClass::Whitespace,
Class::Comment => TokenClass::Comment,
Class::Punctuation => {
// The raw scanner joins comparison operators. Legacy syntax
// classified their ASCII characters individually.
for at in span.start..span.end {
let class = if b"{}();,[]:=@".contains(&source.as_bytes()[at]) {
TokenClass::Punctuation
} else {
TokenClass::Error
};
out.push(TokenSpan {
class,
span: Span {
start: at,
end: at + 1,
},
});
}
continue;
}
Class::Error => TokenClass::Error,
Class::Number => unreachable!("number tokens start with a digit"),
};
out.push(TokenSpan { class, span });
}
out
}
#[cfg(test)]
mod tests {
use super::super::{TokenClass, tokenize};
#[test]
fn legacy_adapter_retains_authored_numeric_operator_and_trivia_tokens() {
use TokenClass::*;
let cases: &[(&str, &[(TokenClass, &str)])] = &[
(
"-1.5 1e-2 -0",
&[
(Number, "-1.5"),
(Whitespace, " "),
(Number, "1e-2"),
(Whitespace, " "),
(Number, "-0"),
],
),
(
"1-2 1 -2 1 - 2",
&[
(Error, "1-2"),
(Whitespace, " "),
(Number, "1"),
(Whitespace, " "),
(Number, "-2"),
(Whitespace, " "),
(Number, "1"),
(Whitespace, " "),
(Error, "-"),
(Whitespace, " "),
(Number, "2"),
],
),
(
"+1 .5 1_0 0x1 1e 1e+",
&[
(Error, "+1"),
(Whitespace, " "),
(Error, ".5"),
(Whitespace, " "),
(Error, "1_0"),
(Whitespace, " "),
(Error, "0x1"),
(Whitespace, " "),
(Error, "1e"),
(Whitespace, " "),
(Error, "1e+"),
],
),
(
"<= != == >= / * $ !",
&[
(Error, "<"),
(Punctuation, "="),
(Whitespace, " "),
(Error, "!"),
(Punctuation, "="),
(Whitespace, " "),
(Punctuation, "="),
(Punctuation, "="),
(Whitespace, " "),
(Error, ">"),
(Punctuation, "="),
(Whitespace, " "),
(Error, "/"),
(Whitespace, " "),
(Error, "*"),
(Whitespace, " "),
(Error, "$"),
(Whitespace, " "),
(Error, "!"),
],
),
(
"// π\r\n\"雪\" @alias",
&[
(Comment, "// π\r"),
(Whitespace, "\n"),
(String, "\"雪\""),
(Whitespace, " "),
(Punctuation, "@"),
(Keyword, "alias"),
],
),
("\"incomplete 雪", &[(Error, "\"incomplete 雪")]),
("\"bad\\q\"", &[(Error, "\"bad\\q\"")]),
(
"雪\u{b}a",
&[(Error, "雪"), (Error, "\u{b}"), (Identifier, "a")],
),
(
"unit calculation quantity_kind if and List Number",
&[
(Identifier, "unit"),
(Whitespace, " "),
(Identifier, "calculation"),
(Whitespace, " "),
(Identifier, "quantity_kind"),
(Whitespace, " "),
(Identifier, "if"),
(Whitespace, " "),
(Identifier, "and"),
(Whitespace, " "),
(Identifier, "List"),
(Whitespace, " "),
(Identifier, "Number"),
],
),
];
for (source, expected) in cases {
let actual = tokenize(source);
assert_eq!(actual.len(), expected.len(), "{source:?}");
let mut offset = 0;
for (token, (class, spelling)) in actual.iter().zip(*expected) {
assert_eq!(token.class, *class, "{source:?} at {offset}");
assert_eq!(token.span.start, offset);
offset += spelling.len();
assert_eq!(token.span.end, offset);
assert_eq!(&source[token.span.start..token.span.end], *spelling);
}
assert_eq!(offset, source.len());
}
}
}
@@ -0,0 +1,52 @@
//! Convert only source3 lowering scratch state into its frozen migration input.
//! This is not a current-to-legacy exporter: fields outside that scratch state
//! must reject instead of being silently discarded.
use crate::Project;
use crate::persistence::{FileError, v3};
use serde_json::{Map, Value};
pub(super) fn from_scratch(
project: Project,
legacy_elements: Map<String, Value>,
) -> Result<v3::Project3, FileError> {
// No `..`: a future Project field requires an explicit compatibility decision.
let Project {
schema_version,
id,
alias,
name,
description,
definitions,
elements,
relations,
diagrams,
artifact_links,
traces,
} = project;
if schema_version != 3 {
return Err(FileError::Invalid(
"Expected source3 lowering state.".into(),
));
}
if !definitions.is_empty() || !elements.is_empty() {
return Err(FileError::Invalid(
"Source3 lowering state cannot contain current definitions or elements.".into(),
));
}
Ok(v3::Project3 {
schema_version,
id,
alias,
name,
description,
elements: serde_json::from_value(Value::Object(legacy_elements))?,
// Keep the frozen nested contracts, including strict trace baselines.
relations: serde_json::from_value(serde_json::to_value(relations)?)?,
diagrams,
artifact_links,
traces: serde_json::from_value(serde_json::to_value(traces)?)?,
})
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,188 @@
use super::*;
use crate::{Id, language, persistence};
use serde_json::json;
const SOURCE3: &str = include_str!("../../../tests/fixtures/v3-data-contract.arcadia");
const JSON3: &[u8] = include_bytes!("../../../tests/fixtures/v3-data-contract.json");
const PROSE: &str = r#"arcadia 3;
project review "Review" id "00000000-0000-0000-0000-000000000001" {
oa_activity reviewed_activity "Live OA" id "00000000-0000-0000-0000-000000000002" {}
sa_function function "Live SA" id "00000000-0000-0000-0000-000000000003" {}
trace review_link "Review link" id "00000000-0000-0000-0000-000000000004" {
oa @reviewed_activity; sa @function; kind Refinement;
baseline {
oa { name "Approved OA"; description "First\nΩ rationale"; };
sa { name "Approved SA"; description "Prior SA prose"; };
};
}
}"#;
fn id(value: u128) -> Id {
Id::from_u128(value)
}
fn prose_json() -> Value {
json!({
"schema_version":3,"id":id(1),"alias":"review","name":"Review","description":"",
"elements": {
id(2).to_string(): {"id":id(2),"alias":"reviewed_activity","name":"Live OA","description":"","parent":null,"body":{"Oa":"Activity"}},
id(3).to_string(): {"id":id(3),"alias":"function","name":"Live SA","description":"","parent":null,"body":{"Sa":"Function"}}
},
"relations":{},"diagrams":{},"artifact_links":{},
"traces": {id(4).to_string():{
"id":id(4),"alias":"review_link","name":"Review link","description":"",
"oa":id(2),"sa":id(3),"kind":"Refinement",
"baseline":{"oa":{"name":"Approved OA","description":"First\nΩ rationale"},"sa":{"name":"Approved SA","description":"Prior SA prose"}}
}}
})
}
#[test]
fn frozen_source3_and_json3_have_complete_model_and_migration_parity() {
let source = language::parse_with_report(SOURCE3).unwrap();
let json = persistence::decode_with_report(JSON3).unwrap();
assert_eq!(source.project, json.project);
assert_eq!(source.migration, json.migration);
assert!(source.project.definitions.is_empty());
let report = source.migration.unwrap();
assert_eq!(
(report.from_schema, report.to_schema),
(3, crate::SCHEMA_VERSION)
);
// This tests direct legacy lowering. Canonical source8 reopening remains a
// separate, unchanged public integration gate.
}
#[test]
fn frozen_wire_still_rejects_new_unknown_and_duplicate_fields() {
let original: Value = serde_json::from_slice(JSON3).unwrap();
for definitions in [
json!({}),
Value::Null,
json!({id(90).to_string(): {"name":"new"}}),
] {
let mut bad = original.clone();
bad["definitions"] = definitions;
let error = persistence::decode(&serde_json::to_vec(&bad).unwrap())
.err()
.unwrap()
.to_string();
assert!(error.contains("unknown field `definitions`"), "{error}");
}
let mut bad = original;
bad["unrecognized"] = json!(true);
assert!(
persistence::decode(&serde_json::to_vec(&bad).unwrap())
.err()
.unwrap()
.to_string()
.contains("unknown field `unrecognized`")
);
let raw = std::str::from_utf8(JSON3).unwrap();
let duplicate = raw.replacen(
"\"schema_version\": 3",
"\"schema_version\": 3, \"schema_version\": 3",
1,
);
assert_ne!(duplicate, raw);
assert!(
persistence::decode(duplicate.as_bytes())
.err()
.unwrap()
.to_string()
.contains("duplicate")
);
}
#[test]
fn old_review_prose_survives_without_admitting_semantic_fields() {
let source = language::parse_with_report(PROSE).unwrap();
let json =
persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap()).unwrap();
assert_eq!(source.project, json.project);
assert_eq!(source.migration, json.migration);
let baseline = source.project.traces[&id(4)].baseline.as_ref().unwrap();
assert_eq!(baseline.oa.name, "Approved OA");
assert_eq!(baseline.oa.description, "First\nΩ rationale");
assert_eq!(baseline.sa.description, "Prior SA prose");
assert!(baseline.semantic.is_none());
for semantic in [Value::Null, json!({"facets":[]})] {
let mut bad = prose_json();
bad["traces"][id(4).to_string()]["baseline"]["semantic"] = semantic;
let error = persistence::decode(&serde_json::to_vec(&bad).unwrap())
.err()
.unwrap()
.to_string();
assert!(error.contains("semantic"), "{error}");
}
for field in ["semantic none;", "semantic { facets []; };"] {
let bad = PROSE.replace("baseline {", &format!("baseline {{ {field}"));
let errors = language::parse(&bad).err().unwrap();
assert!(
errors
.iter()
.any(|e| e.message.contains("require schema/source5")),
"{errors:?}"
);
}
}
#[test]
fn scratch_bridge_refuses_to_discard_current_only_data() {
let mut empty = Project::empty();
empty.schema_version = 3;
assert!(from_scratch(empty.clone(), Map::new()).is_ok());
let mut wrong_version = empty.clone();
wrong_version.schema_version = crate::SCHEMA_VERSION;
assert!(from_scratch(wrong_version, Map::new()).is_err());
let mut definition = empty;
definition.definitions.insert(
id(90),
crate::analysis::Definition {
id: id(90),
alias: "dimensionless".into(),
name: "Dimensionless".into(),
description: String::new(),
body: crate::analysis::DefinitionBody::QuantityKind(
crate::analysis::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::ZERO,
algebra: crate::analysis::QuantityAlgebra::Linear,
},
),
},
);
assert!(
from_scratch(definition, Map::new())
.err()
.unwrap()
.to_string()
.contains("definitions or elements")
);
let mut current = persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap())
.unwrap()
.project;
current.schema_version = 3;
assert!(
from_scratch(current.clone(), Map::new())
.err()
.unwrap()
.to_string()
.contains("definitions or elements")
);
current.elements.clear();
current
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(crate::semantic::SemanticBaseline { facets: Vec::new() });
assert!(
from_scratch(current, Map::new())
.err()
.unwrap()
.to_string()
.contains("semantic")
);
}
+2
View File
@@ -1,4 +1,6 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod analysis;
pub mod analysis_numeric;
pub mod data;
pub mod language;
pub mod model;
+1
View File
@@ -75,6 +75,7 @@ impl Project4 {
alias: self.alias,
name: self.name,
description: self.description,
definitions: BTreeMap::new(),
elements: self.elements,
relations: self.relations,
diagrams: self.diagrams,
+62
View File
@@ -0,0 +1,62 @@
//! Frozen schema5/6/7 top-level envelope. No schema8 definition catalog/defaults.
//! Unchanged nested records are shared only after the closed old-kind check.
use crate::persistence::FileError;
use crate::*;
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project7 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace>,
}
impl Project7 {
pub(crate) fn into_current(self) -> Result<Project, FileError> {
if !(5..=7).contains(&self.schema_version) {
return Err(FileError::Invalid(
"Expected frozen schema5, 6 or 7.".into(),
));
}
reject_elements(self.elements.values())?;
Ok(Project {
schema_version: self.schema_version,
id: self.id,
alias: self.alias,
name: self.name,
description: self.description,
definitions: BTreeMap::new(),
elements: self.elements,
relations: self.relations,
diagrams: self.diagrams,
artifact_links: self.artifact_links,
traces: self.traces,
})
}
}
pub(crate) fn reject_elements<'a>(
elements: impl Iterator<Item = &'a Element>,
) -> Result<(), FileError> {
for element in elements {
if element.kind().is_analysis() {
return Err(FileError::Invalid(format!(
"{:?} requires schema/source 8.",
element.kind()
)));
}
}
Ok(())
}
+43 -3
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 7;
pub const SCHEMA_VERSION: u32 = 8;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -64,9 +64,12 @@ pub enum Kind {
Terminate,
Event,
Constraint,
EngineeringProperty,
AnalysisCase,
AnalysisCheck,
}
impl Kind {
pub const ALL: [Self; 39] = [
pub const ALL: [Self; 42] = [
Self::Entity,
Self::Actor,
Self::Role,
@@ -106,6 +109,9 @@ impl Kind {
Self::Terminate,
Self::Event,
Self::Constraint,
Self::EngineeringProperty,
Self::AnalysisCase,
Self::AnalysisCheck,
];
pub fn label(self) -> &'static str {
match self {
@@ -148,6 +154,9 @@ impl Kind {
Self::Terminate => "Terminate",
Self::Event => "Event",
Self::Constraint => "Constraint",
Self::EngineeringProperty => "Engineering property",
Self::AnalysisCase => "Analysis case",
Self::AnalysisCheck => "Analysis check",
}
}
pub fn is_performer(self) -> bool {
@@ -156,6 +165,9 @@ impl Kind {
Self::Entity | Self::Actor | Self::System | Self::SystemActor
)
}
pub fn is_analysis(self) -> bool {
matches!(self, Self::EngineeringProperty | Self::AnalysisCase | Self::AnalysisCheck)
}
pub fn is_work(self) -> bool {
matches!(self, Self::Activity | Self::Function)
}
@@ -652,6 +664,23 @@ pub enum SharedBody {
expression: String,
constrained: Vec<Id>,
},
EngineeringProperty {
subject: crate::analysis::AnalysisSubject,
value_type: crate::analysis::AnalysisType,
display_unit: Option<crate::analysis::DefinitionRef>,
base: crate::analysis::Binding,
},
AnalysisCase {
subject: Option<crate::analysis::AnalysisSubject>,
base: crate::analysis::BaselineBase,
#[serde(deserialize_with = "unique_id_map")]
overrides: BTreeMap<Id, crate::analysis::Binding>,
},
AnalysisCheck {
subject: Option<crate::analysis::AnalysisSubject>,
documented_constraint: Option<Id>,
predicate: crate::analysis::Binding,
},
}
impl Element {
pub fn layer(&self) -> Layer {
@@ -752,7 +781,8 @@ impl ElementBody {
}
}
pub fn new(kind: Kind, layer: Layer) -> Option<Self> {
if !kind.supports(layer) {
// Analysis creation requires explicitly authored types and bindings.
if !kind.supports(layer) || kind.is_analysis() {
return None;
}
Some(match kind {
@@ -864,6 +894,9 @@ impl SharedBody {
Self::Region => Kind::Region,
Self::State { kind, .. } => kind.kind(),
Self::Constraint { .. } => Kind::Constraint,
Self::EngineeringProperty { .. } => Kind::EngineeringProperty,
Self::AnalysisCase { .. } => Kind::AnalysisCase,
Self::AnalysisCheck { .. } => Kind::AnalysisCheck,
}
}
}
@@ -1625,6 +1658,8 @@ pub struct Project {
pub name: String,
pub description: String,
#[serde(deserialize_with = "unique_id_map")]
pub definitions: BTreeMap<Id, crate::analysis::Definition>,
#[serde(deserialize_with = "unique_id_map")]
pub elements: BTreeMap<Id, Element>,
#[serde(deserialize_with = "unique_id_map")]
pub relations: BTreeMap<Id, Relation>,
@@ -1717,6 +1752,7 @@ impl Project {
alias: "mission".into(),
name: "Untitled mission".into(),
description: String::new(),
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
@@ -1726,6 +1762,7 @@ impl Project {
}
pub fn identities(&self) -> Vec<(Id, &str)> {
let mut out = vec![(self.id, self.alias.as_str())];
out.extend(self.definitions.values().map(|v| (v.id, v.alias.as_str())));
out.extend(self.elements.values().map(|v| (v.id, v.alias.as_str())));
out.extend(self.relations.values().map(|v| (v.id, v.alias.as_str())));
out.extend(self.diagrams.values().map(|v| (v.id, v.alias.as_str())));
@@ -1783,6 +1820,9 @@ impl Project {
if id == self.id {
return &self.name;
}
if let Some(v) = self.definitions.get(&id) {
return &v.name;
}
if let Some(v) = self.elements.get(&id) {
return &v.name;
}
+209 -97
View File
@@ -2,6 +2,10 @@ use crate::*;
use serde::{Deserialize, Serialize};
use std::{io::Write, path::Path};
use thiserror::Error;
mod json_codec;
#[cfg(test)]
#[path = "persistence/source_save_tests.rs"]
mod source_save_tests;
#[path = "migration_v2/mod.rs"]
pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
@@ -9,6 +13,8 @@ pub(crate) use v2::adapter::migrate as migrate_v2_limited;
pub(crate) mod v3;
#[path = "migration_v4.rs"]
pub(crate) mod v4;
#[path = "migration_v7.rs"]
pub(crate) mod v7;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -130,6 +136,19 @@ pub fn decode_limited(
pub fn decode_with_report_limited(
bytes: &[u8],
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
decode_with_report_profile(
bytes,
limits,
json_codec::Profile::with_byte_limit(limits.max_source_bytes),
json_codec::DEPTH_128_VALIDATED,
)
}
fn decode_with_report_profile(
bytes: &[u8],
limits: crate::language::ParseLimits,
profile: json_codec::Profile,
depth_128: bool,
) -> Result<DecodeResult, FileError> {
if bytes.len() > limits.max_source_bytes {
return Err(FileError::Limit(format!(
@@ -138,65 +157,43 @@ pub fn decode_with_report_limited(
)));
}
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
let header: serde_json::Value = serde_json::from_slice(bytes)?;
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 6)
{
reject_source7_fields(&header).map_err(FileError::Invalid)?;
json_codec::preflight(bytes, profile)?;
let version = json_codec::version(bytes)?;
if version.is_some_and(|version| version <= 6) {
let header = json_codec::legacy(bytes)?;
let result = check_legacy_header(&header, version);
json_codec::discard_value(header);
result?;
}
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 5)
&& let Some(diagrams) = header
.get("diagrams")
.and_then(serde_json::Value::as_object)
{
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
for key in diagram.keys() {
if key == "view_options" {
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if !matches!(
key.as_str(),
"id" | "alias"
| "name"
| "description"
| "layer"
| "projection"
| "nodes"
| "routes"
) {
return Err(FileError::Invalid(format!(
"Unknown diagram field '{key}' in the frozen pre6 contract."
)));
}
}
}
}
match header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
{
match version {
Some(1) => {
let old = serde_json::from_slice(bytes)?;
let old = json_codec::legacy(bytes)?;
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
migrate_v1_with_report_limited(old, limits)?
}
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
Some(7) => DecodeResult {
project: serde_json::from_slice(bytes)?,
Some(2) => migrate_v2_limited(json_codec::legacy(bytes)?, limits)?,
Some(3) => v3::migrate(json_codec::legacy(bytes)?, limits)?,
Some(4) => v4::migrate(json_codec::legacy(bytes)?, limits)?,
Some(5) => migrate_v5(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(6) => migrate_v6(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(7) => migrate_v7(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(8) => DecodeResult {
project: json_codec::current(bytes, profile, depth_128)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8."
.into(),
));
}
}
@@ -225,6 +222,38 @@ pub fn decode_with_report_limited(
Ok(decoded)
}
fn check_legacy_header(header: &serde_json::Value, version: Option<u64>) -> Result<(), FileError> {
reject_source7_fields(header).map_err(FileError::Invalid)?;
if version.is_some_and(|version| version <= 5)
&& let Some(diagrams) = header
.get("diagrams")
.and_then(serde_json::Value::as_object)
{
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
for key in diagram.keys() {
if key == "view_options" {
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if !matches!(
key.as_str(),
"id" | "alias"
| "name"
| "description"
| "layer"
| "projection"
| "nodes"
| "routes"
) {
return Err(FileError::Invalid(format!(
"Unknown diagram field '{key}' in the frozen pre6 contract."
)));
}
}
}
}
Ok(())
}
/// Versions 4 and 5 share unchanged typed records, but their constructor set
/// remains frozen. Apply this before normalizing the advertised version.
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
@@ -348,6 +377,7 @@ pub(crate) fn migrate_v5(
if project.schema_version != 5 {
return Err(FileError::Invalid("Expected schema_version 5.".into()));
}
reject_pre8_project(&project)?;
check_pre6_relations(project.relations.values())?;
check_pre6_elements(project.elements.values())?;
for baseline in project
@@ -368,48 +398,49 @@ pub(crate) fn migrate_v5(
/// Reject explicit new fields before serde defaults can erase their presence.
/// This is also applied to source's resolved structured values.
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
let mut pending = vec![value];
while let Some(value) = pending.pop() {
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
}
if fields.get("Relation").and_then(serde_json::Value::as_str)
== Some("ContextualLink")
{
return Err("ContextualLink view selectors require schema/source 7.".into());
}
if fields.keys().any(|key| {
matches!(
key.as_str(),
"Operation"
| "ContextOwner"
| "ContextSource"
| "ContextTarget"
| "ContextPurpose"
)
}) {
return Err(
"Operation/contextual-link semantic facets require schema/source 7.".into(),
);
}
if fields
.get("Exchange")
.and_then(serde_json::Value::as_object)
.is_some_and(|body| body.contains_key("operation"))
{
return Err(
"Exchange.operation requires schema/source 7, including explicit null."
.into(),
);
}
pending.extend(fields.values().rev());
}
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
{
return Err("ContextualLink view selectors require schema/source 7.".into());
}
if fields.keys().any(|key| {
matches!(
key.as_str(),
"Operation"
| "ContextOwner"
| "ContextSource"
| "ContextTarget"
| "ContextPurpose"
)
}) {
return Err(
"Operation/contextual-link semantic facets require schema/source 7.".into(),
);
}
if fields
.get("Exchange")
.and_then(serde_json::Value::as_object)
.is_some_and(|body| body.contains_key("operation"))
{
return Err(
"Exchange.operation requires schema/source 7, including explicit null.".into(),
);
}
for nested in fields.values() {
reject_source7_fields(nested)?;
serde_json::Value::Array(values) => {
pending.extend(values.iter().rev());
}
_ => {}
}
serde_json::Value::Array(values) => {
for nested in values {
reject_source7_fields(nested)?;
}
}
_ => {}
}
Ok(())
}
@@ -420,6 +451,7 @@ pub(crate) fn migrate_v6(
if project.schema_version != 6 {
return Err(FileError::Invalid("Expected schema_version 6.".into()));
}
reject_pre8_project(&project)?;
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
@@ -429,6 +461,30 @@ pub(crate) fn migrate_v6(
})
}
pub(crate) fn migrate_v7(
mut project: Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if project.schema_version != 7 {
return Err(FileError::Invalid("Expected schema_version 7.".into()));
}
reject_pre8_project(&project)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
Ok(DecodeResult {
project,
migration: Some(MigrationReport::data_upgrade(7)),
})
}
fn reject_pre8_project(project: &Project) -> Result<(), FileError> {
if !project.definitions.is_empty() {
return Err(FileError::Invalid(
"Definitions require schema/source 8.".into(),
));
}
v7::reject_elements(project.elements.values())
}
pub(crate) fn migrate_v1_with_report_limited(
old: v1::Project,
limits: crate::language::ParseLimits,
@@ -846,6 +902,9 @@ pub(crate) mod v1 {
}
fn validate_project(p: &Project) -> Result<(), FileError> {
json_codec::document_stack(|| validate_project_inner(p))
}
fn validate_project_inner(p: &Project) -> Result<(), FileError> {
if let Some(i) = validation::validate(p)
.into_iter()
.find(|i| i.severity == validation::Severity::Error)
@@ -861,7 +920,10 @@ pub fn load(path: &Path) -> Result<Project, FileError> {
load_with_report(path).map(|decoded| decoded.project)
}
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
decode_with_report(&std::fs::read(path)?)
decode_with_report(&json_codec::read_bounded(
path,
json_codec::MAX_DOCUMENT_BYTES,
)?)
}
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
@@ -874,19 +936,69 @@ pub fn save_checked(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
) -> Result<Vec<u8>, FileError> {
save_checked_profile(
path,
p,
expected,
json_codec::Profile::DOCUMENT,
json_codec::DEPTH_128_VALIDATED,
)
}
fn save_checked_profile(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
profile: json_codec::Profile,
depth_128: bool,
) -> Result<Vec<u8>, FileError> {
save_checked_profile_with_source(path, p, expected, profile, depth_128, |project| {
crate::language::format(project).into_bytes()
})
}
// Keep the source writer replaceable only at this private seam so regressions
// can prove that malformed or lossy output never reaches filesystem staging.
fn save_checked_profile_with_source(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
profile: json_codec::Profile,
depth_128: bool,
source: impl FnOnce(&Project) -> Vec<u8>,
) -> Result<Vec<u8>, FileError> {
validate_project(p)?;
let mut bytes = if path
let is_source = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
crate::language::format(p).into_bytes()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"));
let mut bytes = if is_source {
source(p)
} else {
serde_json::to_vec_pretty(p)?
json_codec::encode(p, profile, depth_128)?
};
if !bytes.ends_with(b"\n") {
bytes.push(b'\n');
}
if is_source {
// Check exactly the final bytes, including their newline, through the
// public reader before creating a temp file or replacing a destination.
let decoded = decode_with_report_limited(
&bytes,
crate::language::ParseLimits {
max_source_bytes: profile.bytes,
..crate::language::ParseLimits::UNLIMITED
},
)?;
if decoded.project != *p {
return Err(FileError::Invalid(
"Generated source does not reproduce the complete requested project.".into(),
));
}
if decoded.migration.is_some() {
return Err(FileError::Invalid(
"Generated source unexpectedly requires migration.".into(),
));
}
}
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
@@ -895,8 +1007,8 @@ pub fn save_checked(
temp.write_all(&bytes)?;
temp.as_file().sync_all()?;
if let Some(expected) = expected {
match std::fs::read(path) {
Ok(current) if current == expected => {}
match json_codec::matches_file(path, expected) {
Ok(true) => {}
Ok(_) => return Err(FileError::Conflict),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(FileError::Conflict),
Err(e) => return Err(FileError::Io(e)),
@@ -0,0 +1,427 @@
//! Private persistence JSON boundary. Scan original bytes before Serde allocates;
//! the scanner is a resource meter, never a second accepting JSON grammar.
use super::FileError;
use crate::{
Project,
analysis::expression_serde::{self, Scope},
};
use serde::{
Deserialize,
de::{IgnoredAny, MapAccess, Visitor},
};
use std::{
fmt,
io::{self, Read, Write},
};
pub(super) const MAX_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
const MAX_CONTAINERS: usize = 512;
// This switch stays false until Windows/Linux debug/release codec + ordinary
// lifecycle gates pass. Tests exercise the candidate through the same private
// entry point with depth_128=true. It is not a user-controlled parser option.
pub(super) const DEPTH_128_VALIDATED: bool = false;
/// Allocation cardinalities, independent of graph admission and numeric work.
/// The default deliberately admits the existing CLI's 100000 graph objects,
/// 250000 records and 500000 ordered references within its 64 MiB envelope.
/// Strings have no new 1 MiB restriction: any span fitting that envelope fits.
/// These are explicit slot/text bounds, not an assertion of process RSS.
#[derive(Clone, Copy, Debug)]
pub(super) struct Profile {
pub bytes: usize,
pub tokens: usize,
pub entries: usize,
pub container_entries: usize,
pub string_bytes: usize,
pub total_string_bytes: usize,
pub expression_nodes: usize,
}
impl Profile {
pub const DOCUMENT: Self = Self {
bytes: MAX_DOCUMENT_BYTES,
tokens: 16 * 1024 * 1024,
entries: 8 * 1024 * 1024,
container_entries: 500_000,
string_bytes: MAX_DOCUMENT_BYTES,
total_string_bytes: MAX_DOCUMENT_BYTES,
expression_nodes: expression_serde::MAX_JSON_EXPRESSION_NODES,
};
pub fn with_byte_limit(maximum: usize) -> Self {
Self {
bytes: maximum.min(MAX_DOCUMENT_BYTES),
..Self::DOCUMENT
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct Usage {
pub tokens: usize,
pub entries: usize,
pub string_bytes: usize,
pub deepest_container: usize,
}
#[derive(Clone, Copy)]
struct Container {
delimiter: u8,
entries: usize,
first: bool,
}
const EMPTY: Container = Container {
delimiter: 0,
entries: 0,
first: true,
};
fn limit(message: &str) -> FileError {
FileError::Limit(message.into())
}
fn charge(used: &mut usize, amount: usize, maximum: usize, message: &str) -> Result<(), FileError> {
if amount > maximum.saturating_sub(*used) {
return Err(limit(message));
}
*used += amount;
Ok(())
}
pub(super) fn preflight(bytes: &[u8], profile: Profile) -> Result<Usage, FileError> {
if bytes.len() > profile.bytes {
return Err(limit("JSON input byte limit exceeded."));
}
// Fixed storage: no Vec/Value/String and no recursive calls during preflight.
let mut containers = [EMPTY; MAX_CONTAINERS];
let mut depth = 0usize;
let mut usage = Usage::default();
let mut cursor = 0usize;
while cursor < bytes.len() {
let byte = bytes[cursor];
if byte.is_ascii_whitespace() {
cursor += 1;
continue;
}
charge(
&mut usage.tokens,
1,
profile.tokens,
"JSON token limit exceeded.",
)?;
if !matches!(byte, b',' | b':' | b'}' | b']') && depth > 0 {
let frame = &mut containers[depth - 1];
if frame.first {
charge(
&mut frame.entries,
1,
profile.container_entries,
"JSON container entry limit exceeded.",
)?;
charge(
&mut usage.entries,
1,
profile.entries,
"JSON aggregate entry limit exceeded.",
)?;
frame.first = false;
}
}
match byte {
b'{' | b'[' => {
if depth == MAX_CONTAINERS {
return Err(limit("JSON container depth exceeds 512."));
}
containers[depth] = Container {
delimiter: byte,
..EMPTY
};
depth += 1;
usage.deepest_container = usage.deepest_container.max(depth);
cursor += 1;
}
b'}' | b']' => {
if depth == 0
|| !matches!(
(containers[depth - 1].delimiter, byte),
(b'{', b'}') | (b'[', b']')
)
{
return Err(FileError::Invalid(
"Mismatched JSON container delimiter.".into(),
));
}
depth -= 1;
cursor += 1;
}
b',' => {
if depth > 0 {
containers[depth - 1].first = true;
}
cursor += 1;
}
b':' => cursor += 1,
b'"' => {
cursor += 1;
let start = cursor;
let mut closed = false;
while cursor < bytes.len() {
match bytes[cursor] {
b'"' => {
closed = true;
break;
}
b'\\' => {
// Skip exactly one escaped byte, including an escaped
// backslash. Serde validates actual escapes/UTF-8.
cursor += 1;
if cursor < bytes.len() {
cursor += 1;
}
}
_ => cursor += 1,
}
}
let span = cursor - start;
if span > profile.string_bytes {
return Err(limit("JSON string byte limit exceeded."));
}
charge(
&mut usage.string_bytes,
span,
profile.total_string_bytes,
"JSON aggregate string byte limit exceeded.",
)?;
if !closed {
return Err(FileError::Invalid("Unterminated JSON string.".into()));
}
cursor += 1;
}
_ => {
// Count a scalar token without interpreting its value. Invalid
// tokens and invalid whitespace must still fail the Serde pass.
cursor += 1;
while cursor < bytes.len()
&& !bytes[cursor].is_ascii_whitespace()
&& !matches!(
bytes[cursor],
b'{' | b'[' | b'}' | b']' | b',' | b':' | b'"'
)
{
cursor += 1;
}
}
}
}
if depth != 0 {
return Err(FileError::Invalid("Unclosed JSON container.".into()));
}
Ok(usage)
}
struct Version(Option<u64>);
impl<'de> Deserialize<'de> for Version {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Header;
impl<'de> Visitor<'de> for Header {
type Value = Version;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a project header")
}
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Version, A::Error> {
#[derive(Deserialize)]
#[serde(field_identifier)]
enum Key {
#[serde(rename = "schema_version")]
Schema,
#[serde(other)]
Other,
}
let mut version = None;
while let Some(key) = access.next_key::<Key>()? {
match key {
Key::Schema => {
if version.is_some() {
return Err(serde::de::Error::duplicate_field("schema_version"));
}
version = Some(access.next_value()?);
}
Key::Other => {
access.next_value::<IgnoredAny>()?;
}
}
}
Ok(Version(version))
}
}
deserializer.deserialize_map(Header)
}
}
/// IgnoredAny in serde_json walks arbitrary ignored containers iteratively.
/// This probe does not materialize ignored subtrees. Serde's scratch buffer for
/// container markers/escaped keys is bounded by the preceding raw scan. It
/// consumes the entire original stream and rejects duplicate/escaped versions.
pub(super) fn version(bytes: &[u8]) -> Result<Option<u64>, FileError> {
document_stack(|| {
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
let version = Version::deserialize(&mut deserializer)?;
deserializer.end()?;
Ok(version.0)
})
}
pub(super) fn document_stack<R>(operation: impl FnOnce() -> R) -> R {
// Non-Expr Project and historical schemas are finitely nested by their
// concrete Rust types. Reserve for that outer generated Serde frame chain;
// only recursive Expr edges use the smaller repeated boundary guard.
stacker::maybe_grow(512 * 1024, 2 * 1024 * 1024, operation)
}
pub(super) fn current(
bytes: &[u8],
profile: Profile,
depth_128: bool,
) -> Result<Project, FileError> {
preflight(bytes, profile)?;
let _scope = Scope::decoding(profile.expression_nodes);
document_stack(|| {
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
if depth_128 {
deserializer.disable_recursion_limit();
}
let project = Project::deserialize(&mut deserializer)?;
deserializer.end()?;
Ok(project)
})
}
pub(super) fn legacy<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, FileError> {
// Never disable the legacy recursion guard, including its temporary Value.
document_stack(|| Ok(serde_json::from_slice(bytes)?))
}
pub(super) fn discard_value(value: serde_json::Value) {
let mut pending = vec![value];
while let Some(value) = pending.pop() {
match value {
serde_json::Value::Array(values) => pending.extend(values),
serde_json::Value::Object(values) => pending.extend(values.into_values()),
_ => {}
}
}
}
fn reserve_bounded(bytes: &mut Vec<u8>, additional: usize, maximum: usize) -> io::Result<()> {
let required = bytes.len() + additional;
if required > bytes.capacity() {
let next = bytes
.capacity()
.max(4096)
.saturating_mul(2)
.max(required)
.min(maximum);
bytes
.try_reserve_exact(next - bytes.len())
.map_err(io::Error::other)?;
}
Ok(())
}
struct CappedWriter {
bytes: Vec<u8>,
maximum: usize,
exceeded: bool,
}
impl CappedWriter {
fn new(maximum: usize) -> Self {
Self {
bytes: Vec::new(),
maximum,
exceeded: false,
}
}
}
impl Write for CappedWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if bytes.len() > self.maximum.saturating_sub(self.bytes.len()) {
self.exceeded = true;
return Err(io::Error::other("JSON output byte limit exceeded."));
}
// Check before reserve/extend; failed output never creates a giant Vec.
reserve_bounded(&mut self.bytes, bytes.len(), self.maximum)?;
self.bytes.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
/// Compact output avoids indentation multiplying the cost of legal depth128.
/// The returned bytes have already passed the identical reader resource profile.
pub(super) fn encode(
project: &Project,
profile: Profile,
depth_128: bool,
) -> Result<Vec<u8>, FileError> {
let _scope = Scope::encoding(profile.expression_nodes);
let mut writer = CappedWriter::new(profile.bytes);
let result = document_stack(|| serde_json::to_writer(&mut writer, project));
if writer.exceeded {
return Err(limit("JSON output byte limit exceeded."));
}
result?;
writer.write_all(b"\n").map_err(|error| {
if writer.exceeded {
limit("JSON output byte limit exceeded.")
} else {
FileError::Io(error)
}
})?;
// Establish save/reopen symmetry before filesystem mutation, including the
// conservative production guard until the deeper profile is validated.
let reopened = current(&writer.bytes, profile, depth_128)?;
if reopened != *project {
return Err(FileError::Invalid(
"JSON serialization did not preserve the project value.".into(),
));
}
Ok(writer.bytes)
}
pub(super) fn read_bounded(path: &std::path::Path, maximum: usize) -> Result<Vec<u8>, FileError> {
let mut file = std::fs::File::open(path)?;
if file.metadata()?.len() > maximum as u64 {
return Err(limit("Input file byte limit exceeded."));
}
let mut bytes = Vec::new();
let mut chunk = [0u8; 8192];
loop {
let count = file.read(&mut chunk)?;
if count == 0 {
return Ok(bytes);
}
if count > maximum.saturating_sub(bytes.len()) {
return Err(limit("Input file byte limit exceeded."));
}
reserve_bounded(&mut bytes, count, maximum)?;
bytes.extend_from_slice(&chunk[..count]);
}
}
pub(super) fn matches_file(path: &std::path::Path, expected: &[u8]) -> io::Result<bool> {
let mut file = std::fs::File::open(path)?;
if file.metadata()?.len() != expected.len() as u64 {
return Ok(false);
}
let mut compared = 0usize;
let mut chunk = [0u8; 8192];
loop {
let count = file.read(&mut chunk)?;
if count == 0 {
return Ok(compared == expected.len());
}
if expected.get(compared..compared + count) != Some(&chunk[..count]) {
return Ok(false);
}
compared += count;
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,612 @@
use super::*;
use crate::{
Element, ElementBody, Id, OaBody,
analysis::{
AnalysisType, Binding, CalculationDefinition, Definition, DefinitionBody, Expr, Parameter,
ParameterId, ScalarType, UnaryOp,
},
language::ParseLimits,
persistence,
};
use std::{
fmt::Write as _,
process::{Command, Stdio},
time::{Duration, Instant},
};
const IDENTITY: &str = "00000000-0000-0000-0000-000000000001";
const PARAMETER: &str = "00000000-0000-0000-0000-000000000002";
const MAIN: &str = "00000000-0000-0000-0000-000000000003";
fn id(value: &str) -> Id {
Id::parse_str(value).unwrap()
}
fn boolean_type() -> AnalysisType {
AnalysisType::Scalar(ScalarType::Boolean)
}
fn definition(id: Id, alias: &str, parameters: Vec<Parameter>, body: Expr) -> Definition {
Definition {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
body: DefinitionBody::Calculation(CalculationDefinition {
parameters,
result: boolean_type(),
body: Binding::Expression(body),
}),
}
}
fn project() -> Project {
let mut project = Project::empty();
project.id = Id::from_u128(1000);
project.definitions.insert(
id(IDENTITY),
definition(
id(IDENTITY),
"identity",
vec![Parameter {
id: ParameterId(id(PARAMETER)),
name: "value".into(),
value_type: boolean_type(),
}],
Expr::Parameter(ParameterId(id(PARAMETER))),
),
);
project.definitions.insert(
id(MAIN),
definition(id(MAIN), "main", vec![], Expr::Boolean(true)),
);
project
}
fn wire(expression: &str) -> Vec<u8> {
let shallow = serde_json::to_string(&project()).unwrap();
assert_eq!(shallow.matches(r#"{"Boolean":true}"#).count(), 1);
shallow
.replace(r#"{"Boolean":true}"#, expression)
.into_bytes()
}
fn chain(depth: usize, call: bool) -> String {
assert!(depth > 0);
let prefix = if call {
format!(r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":"#)
} else {
r#"{"Unary":{"op":"Not","operand":"#.into()
};
let suffix = if call { "}}}" } else { "}}" };
format!(
"{}{}{}",
prefix.repeat(depth - 1),
r#"{"Boolean":true}"#,
suffix.repeat(depth - 1)
)
}
fn candidate(bytes: &[u8], profile: Profile) -> Result<Project, FileError> {
persistence::decode_with_report_profile(bytes, ParseLimits::UNLIMITED, profile, true)
.map(|decoded| decoded.project)
}
#[test]
fn resource_preflight_has_exact_lowerable_boundaries() {
let bytes = br#"{"key":["a\\\"[{}]",0,true],"other":"\u0061"}"#;
let usage = preflight(bytes, Profile::DOCUMENT).unwrap();
let exact = Profile {
bytes: bytes.len(),
tokens: usage.tokens,
entries: usage.entries,
container_entries: 3,
string_bytes: 9,
total_string_bytes: usage.string_bytes,
..Profile::DOCUMENT
};
preflight(bytes, exact).unwrap();
for lower in [
Profile {
bytes: exact.bytes - 1,
..exact
},
Profile {
tokens: exact.tokens - 1,
..exact
},
Profile {
entries: exact.entries - 1,
..exact
},
Profile {
container_entries: 2,
..exact
},
Profile {
total_string_bytes: exact.total_string_bytes - 1,
..exact
},
Profile {
string_bytes: exact.string_bytes - 1,
..exact
},
] {
assert!(matches!(preflight(bytes, lower), Err(FileError::Limit(_))));
}
let deepest = format!("{}0{}", "[".repeat(512), "]".repeat(512));
assert_eq!(
preflight(deepest.as_bytes(), Profile::DOCUMENT)
.unwrap()
.deepest_container,
512
);
assert!(matches!(
preflight(format!("[{deepest}]").as_bytes(), Profile::DOCUMENT),
Err(FileError::Limit(_))
));
// A huge ignored scalar array exhausts preflight before a header is decoded.
assert!(matches!(
preflight(
br#"{"schema_version":8,"junk":[0,0,0,0]}"#,
Profile {
container_entries: 3,
..Profile::DOCUMENT
}
),
Err(FileError::Limit(_))
));
}
#[test]
fn scanner_is_not_a_parallel_json_grammar() {
for bytes in [
br#"{"schema_version":8}{}"#.as_slice(),
br#"{"schema_version":8,}"#,
br#"{"schema_version":8 "junk":0}"#,
br#"{"schema_version":8,"schema_version":8}"#,
br#"{"schema_version":8,"\u0073chema_version":8}"#,
br#"{"schema_version":8,"junk":"\q"}"#,
br#"{"schema_version":8,"junk":"unfinished}"#,
br#"{"schema_version":8,"junk":[}"#,
] {
assert!(candidate(bytes, Profile::DOCUMENT).is_err(), "{bytes:?}");
}
let mut invalid_utf8 = wire(r#"{"Boolean":true}"#);
let offset = invalid_utf8
.windows(7)
.position(|window| window == b"mission")
.unwrap();
invalid_utf8[offset] = 0xff;
assert!(candidate(&invalid_utf8, Profile::DOCUMENT).is_err());
let escaped = String::from_utf8(wire(r#"{"Boolean":true}"#))
.unwrap()
.replace(r#""schema_version""#, r#""\u0073chema_version""#);
assert_eq!(
candidate(escaped.as_bytes(), Profile::DOCUMENT).unwrap(),
project()
);
}
#[test]
fn remote_serde_keeps_each_existing_expression_variant() {
let n = r#"{"Number":{"numerator":"1","denominator":"1"}}"#;
let b = r#"{"Boolean":true}"#;
let expressions = [
b.to_string(),
n.to_string(),
format!(
r#"{{"Quantity":{{"magnitude":{{"numerator":"1","denominator":"1"}},"unit":{{"Local":"{IDENTITY}"}}}}}}"#
),
format!(r#"{{"Property":"{IDENTITY}"}}"#),
format!(r#"{{"Parameter":"{PARAMETER}"}}"#),
format!(r#"{{"List":[{n},{n}]}}"#),
format!(r#"{{"Unary":{{"op":"Not","operand":{b}}}}}"#),
format!(r#"{{"Binary":{{"op":"Add","left":{n},"right":{n}}}}}"#),
format!(r#"{{"If":{{"condition":{b},"when_true":{n},"when_false":{n}}}}}"#),
format!(
r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":{b}}}}}}}"#
),
format!(r#"{{"Sum":{{"values":{{"List":[{n}]}},"initial":{n}}}}}"#),
format!(r#"{{"Power":{{"base":{n},"exponent":2}}}}"#),
format!(r#"{{"AsKind":{{"kind":{{"Local":"{IDENTITY}"}},"value":{n}}}}}"#),
];
for original in expressions {
let value: Expr = serde_json::from_str(&original).unwrap();
let copy = value.clone();
assert!(value == copy);
assert_eq!(serde_json::to_string(&value).unwrap(), original);
assert_eq!(format!("{value:?}"), format!("{copy:?}"));
}
let list = Expr::List(vec![Expr::Boolean(true), Expr::Boolean(false)]);
assert_eq!(format!("{list:?}"), "List([Boolean(true), Boolean(false)])");
assert!(list != Expr::List(vec![Expr::Boolean(false), Expr::Boolean(true)]));
}
#[test]
fn json_profile_does_not_contract_declared_cli_collection_counts() {
// Exercise original-byte typed construction at the existing graph ceiling.
// This gate checks codec/count compatibility, not 100000-object UI speed.
let mut model = Project::empty();
model.id = Id::from_u128(1_000_000);
for value in 1..=100_000 {
let id = Id::from_u128(value);
model.elements.insert(
id,
Element {
id,
alias: format!("activity_{value}"),
name: String::new(),
description: String::new(),
parent: None,
body: ElementBody::Oa(OaBody::Activity),
},
);
}
let bytes = encode(&model, Profile::DOCUMENT, true).unwrap();
let decoded = current(&bytes, Profile::DOCUMENT, true).unwrap();
assert_eq!(decoded.elements.len(), 100_000);
let cli = ParseLimits {
max_source_bytes: 64 * 1024 * 1024,
max_graph_objects: 100_000,
max_records: 250_000,
max_step_references: 500_000,
};
cli.check_model(&decoded).unwrap();
let references = format!(
"[{}\"{IDENTITY}\"]",
format!("\"{IDENTITY}\",").repeat(499_999)
);
preflight(references.as_bytes(), Profile::DOCUMENT).unwrap();
assert!(matches!(
preflight(
format!("[\"{IDENTITY}\",{}]", &references[1..references.len() - 1]).as_bytes(),
Profile::DOCUMENT
),
Err(FileError::Limit(_))
));
// Metadata over the initially proposed 1 MiB limit remains admissible.
let mut text = Project::empty();
text.description = "x".repeat(1024 * 1024 + 1);
let bytes = encode(&text, Profile::DOCUMENT, true).unwrap();
assert_eq!(current(&bytes, Profile::DOCUMENT, true).unwrap(), text);
eprintln!(
"JSON profile: graph bytes={}, ordered-reference bytes={}, max container entries={}",
serde_json::to_string(&model).unwrap().len(),
references.len(),
Profile::DOCUMENT.container_entries
);
}
#[test]
fn production_save_always_reopens_with_its_reader_profile() {
let deep = candidate(&wire(&chain(128, true)), Profile::DOCUMENT).unwrap();
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("project.json");
std::fs::write(&path, b"previous").unwrap();
let saved = persistence::save_checked(&path, &deep, Some(b"previous"));
if DEPTH_128_VALIDATED {
let bytes = saved.unwrap();
assert_eq!(persistence::decode(&bytes).unwrap(), deep);
} else {
assert!(saved.is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"previous");
}
}
fn accepted_phase() {
for call in [false, true] {
let bytes = wire(&chain(128, call));
let usage = preflight(&bytes, Profile::DOCUMENT).unwrap();
let model = candidate(&bytes, Profile::DOCUMENT).unwrap();
let copy = model.clone();
assert!(model == copy);
let debug = format!("{model:?}");
assert!(!debug.is_empty());
let output = encode(&model, Profile::DOCUMENT, true).unwrap();
assert!(candidate(&output, Profile::DOCUMENT).unwrap() == model);
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("deep.json");
let saved = persistence::save_checked_profile(&path, &model, None, Profile::DOCUMENT, true)
.unwrap();
assert!(
candidate(
&read_bounded(&path, MAX_DOCUMENT_BYTES).unwrap(),
Profile::DOCUMENT
)
.unwrap()
== model
);
assert_eq!(saved, output);
eprintln!(
"accepted call={call} depth128 input={} output={} tokens={} entries={} raw strings={} containers={} debug={}",
bytes.len(),
output.len(),
usage.tokens,
usage.entries,
usage.string_bytes,
usage.deepest_container,
debug.len()
);
}
}
fn refused_phase() {
for call in [false, true] {
let error = candidate(&wire(&chain(129, call)), Profile::DOCUMENT).unwrap_err();
assert!(error.to_string().contains("logical depth"), "{error}");
}
let wide = format!(
r#"{{"List":[{}null]}}"#,
r#"{"Boolean":true},"#.repeat(1024)
);
let error = candidate(&wire(&wide), Profile::DOCUMENT).unwrap_err();
assert!(error.to_string().contains("1024"), "{error}");
let bytes = wire(&chain(128, true));
let exact_model = candidate(
&bytes,
Profile {
expression_nodes: 129,
..Profile::DOCUMENT
},
)
.unwrap();
let error = candidate(
&bytes,
Profile {
expression_nodes: 128,
..Profile::DOCUMENT
},
)
.unwrap_err();
// Identity's Parameter plus main's 128-node expression exceed this total.
assert!(error.to_string().contains("allocation count"), "{error}");
let scan = preflight(&bytes, Profile::DOCUMENT).unwrap();
assert!(matches!(
candidate(
&bytes,
Profile {
tokens: scan.tokens - 1,
..Profile::DOCUMENT
}
),
Err(FileError::Limit(_))
));
document_stack(|| {
use crate::analysis::{AdmissionLimits, admit};
let events = admit(&exact_model, AdmissionLimits::REFERENCE)
.unwrap()
.admission_usage()
.static_events;
admit(
&exact_model,
AdmissionLimits {
max_static_events: events,
},
)
.unwrap();
let error = match admit(
&exact_model,
AdmissionLimits {
max_static_events: events - 1,
},
) {
Ok(_) => panic!("Static work below the measured exact count must refuse."),
Err(error) => error,
};
assert_eq!(error.code, "analysis_limit");
});
let mut tree = r#"{"Boolean":true}"#.to_owned();
for _ in 0..15 {
tree = format!(r#"{{"Binary":{{"op":"And","left":{tree},"right":{tree}}}}}"#);
}
// 65535 balanced nodes plus one unary node is the stored-tree boundary.
tree = format!(r#"{{"Unary":{{"op":"Not","operand":{tree}}}}}"#);
let at_tree_limit = wire(&tree);
current(&at_tree_limit, Profile::DOCUMENT, true).unwrap();
let beyond = wire(&format!(r#"{{"Unary":{{"op":"Not","operand":{tree}}}}}"#));
assert!(
current(&beyond, Profile::DOCUMENT, true)
.unwrap_err()
.to_string()
.contains("65536")
);
// No downstream work after refusal poisons the next ordinary operation.
candidate(&wire(&chain(128, true)), Profile::DOCUMENT).unwrap();
}
fn malformed_phase() {
let deep = chain(127, false);
for (label, expression) in [
(
"duplicate operand",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"operand":null}}}}"#),
),
(
"escaped duplicate",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"\u006fperand":null}}}}"#),
),
(
"unknown field",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"surprise":null}}}}"#),
),
(
"duplicate enum",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep}}},"Boolean":true}}"#),
),
(
"duplicate parameter",
format!(
r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":{deep},"\u0030{}":null}}}}}}"#,
&PARAMETER[1..]
),
),
] {
let error = candidate(&wire(&expression), Profile::DOCUMENT).unwrap_err();
assert!(
!error.to_string().contains("logical depth"),
"{label}: {error}"
);
eprintln!("{label}: {error}");
}
let complete = String::from_utf8(wire(&chain(128, true))).unwrap();
let duplicate_late = format!(
r#"{},"name":"late duplicate"}}"#,
&complete[..complete.len() - 1]
);
assert!(candidate(duplicate_late.as_bytes(), Profile::DOCUMENT).is_err());
assert!(candidate(format!("{complete} null").as_bytes(), Profile::DOCUMENT).is_err());
let invalid_surrogate = complete.replace(r#""description":"""#, r#""description":"\uD800""#);
assert!(candidate(invalid_surrogate.as_bytes(), Profile::DOCUMENT).is_err());
// Fully rejected raw depth also tears down without a recursive JSON Value.
let excessive = format!("{}0{}", "[".repeat(100_000), "]".repeat(100_000));
assert!(matches!(
preflight(excessive.as_bytes(), Profile::DOCUMENT),
Err(FileError::Limit(_))
));
}
fn ownership_phase() {
let mut value = Expr::Boolean(true);
for _ in 1..100_000 {
value = Expr::Unary {
op: UnaryOp::Not,
operand: Box::new(value),
};
}
let mut copy = value.clone();
assert!(value == copy);
let mut cursor = &mut copy;
loop {
match cursor {
Expr::Unary { operand, .. } => cursor = operand,
Expr::Boolean(leaf) => {
*leaf = false;
break;
}
_ => unreachable!(),
}
}
assert!(value != copy);
// A streaming formatter avoids retaining a 100000-node Debug string.
struct Counter(usize);
impl fmt::Write for Counter {
fn write_str(&mut self, value: &str) -> fmt::Result {
self.0 += value.len();
Ok(())
}
}
let mut debug = Counter(0);
write!(&mut debug, "{value:?}").unwrap();
assert!(debug.0 > 100_000);
assert!(serde_json::to_writer(io::sink(), &value).is_err());
drop(value);
drop(copy);
eprintln!(
"iterative manual100000 ownership/debug/drop completed; debug bytes={}",
debug.0
);
}
fn atomic_phase() {
let model = project();
let bytes = encode(&model, Profile::DOCUMENT, true).unwrap();
let exact = Profile {
bytes: bytes.len(),
..Profile::DOCUMENT
};
assert_eq!(encode(&model, exact, true).unwrap(), bytes);
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("preserved.json");
std::fs::write(&path, b"original").unwrap();
let error = persistence::save_checked_profile(
&path,
&model,
Some(b"original"),
Profile {
bytes: bytes.len() - 1,
..Profile::DOCUMENT
},
true,
)
.unwrap_err();
assert!(matches!(error, FileError::Limit(_)));
assert_eq!(std::fs::read(&path).unwrap(), b"original");
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
let error = persistence::save_checked_profile(
&path,
&model,
Some(b"different"),
Profile::DOCUMENT,
true,
)
.unwrap_err();
assert!(matches!(error, FileError::Conflict));
assert_eq!(std::fs::read(&path).unwrap(), b"original");
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
assert!(read_bounded(&path, 7).is_err());
assert_eq!(read_bounded(&path, 8).unwrap(), b"original");
let mut writer = CappedWriter::new(3);
writer.write_all(b"abc").unwrap();
let capacity = writer.bytes.capacity();
assert!(writer.write_all(b"d").is_err());
assert_eq!(writer.bytes, b"abc");
assert_eq!(writer.bytes.capacity(), capacity);
}
/// Root runs this exact test in each approved target/profile. Each phase runs in
/// another process so a stack overflow is an explicit failed phase, and inside
/// a 128 KiB thread (smaller than the ordinary test stack). No RUST_MIN_STACK
/// override, ignored test, release-only path or increased test stack.
#[test]
fn codec_lifecycle_subprocess() {
const PHASE: &str = "ARCADIA_CODEC_LIFECYCLE_PHASE";
if let Ok(phase) = std::env::var(PHASE) {
eprintln!("codec child phase={phase}, configured thread stack=131072");
std::thread::Builder::new()
.name(format!("codec-{phase}"))
.stack_size(128 * 1024)
.spawn(move || match phase.as_str() {
"accepted" => accepted_phase(),
"refused" => refused_phase(),
"malformed" => malformed_phase(),
"ownership" => ownership_phase(),
"atomic" => atomic_phase(),
_ => panic!("Unknown codec phase"),
})
.unwrap()
.join()
.unwrap();
return;
}
for phase in ["accepted", "refused", "malformed", "ownership", "atomic"] {
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"persistence::json_codec::tests::codec_lifecycle_subprocess",
"--test-threads=1",
"--nocapture",
])
.env(PHASE, phase)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let start = Instant::now();
loop {
if child.try_wait().unwrap().is_some() {
break;
}
if start.elapsed() > Duration::from_secs(120) {
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
panic!(
"codec {phase} timed out: {}",
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(20));
}
let output = child.wait_with_output().unwrap();
eprintln!(
"codec {phase}: status={} elapsed={:?}\n{}{}",
output.status,
start.elapsed(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.status.success(),
"codec phase {phase} aborted or failed"
);
}
}
@@ -0,0 +1,145 @@
//! Containment while the complete source8 adapter remains unavailable.
use super::*;
use crate::analysis::{Definition, DefinitionBody, QuantityAlgebra, QuantityKindDefinition};
use crate::analysis_numeric::Dimension;
use std::fs;
const ORDINARY_SOURCE7: &str = concat!(
"arcadia 7;\n",
"project saved \"Saved model\" id \"4210b9f2-7046-47f0-aa3b-153f626a579d\" {\n",
" description \"Retain café and exact identity\";\n",
"}",
);
fn ordinary() -> Project {
let decoded = decode_with_report(ORDINARY_SOURCE7.as_bytes()).unwrap();
assert!(decoded.migration.is_some());
decoded.project
}
fn directory_entries(path: &Path) -> Vec<std::ffi::OsString> {
let mut names = fs::read_dir(path)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect::<Vec<_>>();
names.sort();
names
}
// A missing parent makes a mistakenly early NamedTempFile creation observable
// as Io, even when the temp would otherwise be removed again on error.
fn rejected_output(
project: &Project,
output: &[u8],
profile: json_codec::Profile,
check: impl Fn(&FileError),
) {
let root = tempfile::tempdir().unwrap();
let existing = root.path().join("existing.arcadia");
let sentinel = b"external bytes must survive\r\n";
fs::write(&existing, sentinel).unwrap();
for path in [
existing.clone(),
root.path().join("new.arcadia"),
root.path().join("missing-parent/new.arcadia"),
] {
let before = directory_entries(root.path());
let expected = (path == existing).then_some(sentinel.as_slice());
let error =
save_checked_profile_with_source(&path, project, expected, profile, false, |_| {
output.to_vec()
})
.unwrap_err();
check(&error);
assert_eq!(directory_entries(root.path()), before);
assert_eq!(fs::read(&existing).unwrap(), sentinel);
if path != existing {
assert!(!path.exists());
}
}
}
#[test]
fn malformed_generated_source_rejects_before_any_filesystem_staging() {
rejected_output(
&ordinary(),
b"arcadia",
json_codec::Profile::DOCUMENT,
|error| assert!(matches!(error, FileError::Text(_)), "{error}"),
);
}
#[test]
fn parseable_source_omitting_a_definition_cannot_replace_the_whole_project() {
let mut project = ordinary();
let id = Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap();
project.definitions.insert(
id,
Definition {
id,
alias: "duration".into(),
name: "Duration".into(),
description: "Unused definitions are still authored model data.".into(),
body: DefinitionBody::QuantityKind(QuantityKindDefinition {
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
algebra: QuantityAlgebra::Linear,
}),
},
);
validate_project(&project).unwrap();
let decoded = decode(ORDINARY_SOURCE7.as_bytes()).unwrap();
assert_eq!(decoded.id, project.id);
assert_eq!(decoded.name, project.name);
assert_eq!(decoded.elements, project.elements);
assert!(decoded.definitions.is_empty());
assert_ne!(decoded, project);
rejected_output(
&project,
ORDINARY_SOURCE7.as_bytes(),
json_codec::Profile::DOCUMENT,
|error| {
assert!(
matches!(error, FileError::Invalid(message)
if message == "Generated source does not reproduce the complete requested project."),
"{error}"
);
},
);
}
#[test]
fn equal_project_from_generated_legacy_source_still_rejects_migration() {
rejected_output(
&ordinary(),
ORDINARY_SOURCE7.as_bytes(),
json_codec::Profile::DOCUMENT,
|error| {
assert!(
matches!(error, FileError::Invalid(message)
if message == "Generated source unexpectedly requires migration."),
"{error}"
);
},
);
}
#[test]
fn generated_source_admission_checks_final_newline_under_the_effective_byte_limit() {
let project = ordinary();
let profile = json_codec::Profile::with_byte_limit(ORDINARY_SOURCE7.len());
rejected_output(&project, ORDINARY_SOURCE7.as_bytes(), profile, |error| {
assert!(matches!(error, FileError::Limit(_)), "{error}");
});
let mut terminated = ORDINARY_SOURCE7.as_bytes().to_vec();
terminated.push(b'\n');
let profile = json_codec::Profile::with_byte_limit(terminated.len());
// Existing final newline is not added twice: decoding now reaches the
// independent migration refusal, rather than exceeding this exact limit.
rejected_output(&project, &terminated, profile, |error| {
assert!(
matches!(error, FileError::Invalid(message)
if message == "Generated source unexpectedly requires migration."),
"{error}"
);
});
}
+17 -2
View File
@@ -1,8 +1,14 @@
use crate::*;
use std::collections::BTreeSet;
/// Every live reference in a typed element, including structural ownership.
/// Architectural/project references, including analysis subjects/properties.
/// Layer-neutral definition references and scoped parameters have separate domains.
pub fn element_references(e: &Element) -> Vec<Id> {
if e.kind().is_analysis() {
let mut references: Vec<_> = crate::analysis::references::element_model_references(e).into_iter().collect();
references.extend(e.parent);
return references;
}
let mut copy = e.clone();
let mut refs = Vec::new();
visit_element_refs(&mut copy, |id| refs.push(*id));
@@ -14,6 +20,8 @@ pub fn relation_references(r: &Relation) -> Vec<Id> {
visit_relation_refs(&mut copy, |id| refs.push(*id));
refs
}
/// Architectural mutable slots only. Analysis map keys require the fallible
/// whole-project remap; use analysis::references for its typed domains.
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
if let Some(parent) = &mut e.parent {
visit(parent);
@@ -203,7 +211,10 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
_ => {}
}
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
crate::analysis::references::check_remap(p, old, new)
.map_err(|error| ModelError(error.to_string()))?;
crate::analysis::references::remap_project(p, old, new);
for e in p.elements.values_mut() {
visit_element_refs(e, |id| {
if *id == old {
@@ -265,6 +276,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
}
Ok(())
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
@@ -405,6 +417,9 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
.iter()
.map(|id| (*id, p.named(*id).to_string()))
.collect();
// Analysis dependencies remain live: deleting their target rejects the
// transaction unless that transaction also explicitly repairs/removes them.
p.definitions.retain(|id, _| !removed.contains(id));
p.elements.retain(|id, _| !removed.contains(id));
p.relations.retain(|id, _| !removed.contains(id));
p.diagrams.retain(|id, _| !removed.contains(id));
+3
View File
@@ -604,6 +604,9 @@ fn catalog_facets(
include_linked: bool,
) -> Result<Vec<FacetDrift>, String> {
let (oa, sa) = (trace.oa, trace.sa);
if [oa, sa].iter().any(|id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis())) {
return Err("Semantic review of analysis records is not implemented; header-only review is refused.".into());
}
let mut selectors: BTreeSet<_> = scalar_selectors()
.into_iter()
.filter(|s| {
+66 -1
View File
@@ -19,6 +19,21 @@ pub enum Command {
},
CreateElement(Element),
EditElement(Id, Element),
CreateDefinition(crate::analysis::Definition),
EditDefinition(Id, crate::analysis::Definition),
SetAnalysisBase {
property: Id,
binding: crate::analysis::Binding,
},
SetAnalysisOverride {
case: Id,
property: Id,
binding: crate::analysis::Binding,
},
ClearAnalysisOverride {
case: Id,
property: Id,
},
CreateRelation(Relation),
EditRelation(Id, Relation),
CreateDiagram(Diagram),
@@ -453,6 +468,47 @@ fn apply_command(
}
Some(value.id)
}
Command::CreateDefinition(value) => {
vacant(p, value.id)?;
p.definitions.insert(value.id, value.clone());
Some(value.id)
}
Command::EditDefinition(old, value) => {
if !p.definitions.contains_key(old) { return Err(missing()); }
let mut definition = value.clone();
definition.id = *old;
p.definitions.insert(*old, definition);
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::SetAnalysisBase { property, binding } => {
let element = p.elements.get_mut(property).ok_or_else(missing)?;
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared_mut() else {
return Err(ModelError("SetAnalysisBase requires an engineering property.".into()));
};
*base = binding.clone();
Some(*property)
}
Command::SetAnalysisOverride { case, property, binding } => {
let element = p.elements.get_mut(case).ok_or_else(missing)?;
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
return Err(ModelError("SetAnalysisOverride requires an analysis case.".into()));
};
overrides.insert(*property, binding.clone());
Some(*case)
}
Command::ClearAnalysisOverride { case, property } => {
let target = p.elements.get(property).ok_or_else(missing)?;
if target.kind() != Kind::EngineeringProperty { return Err(ModelError("ClearAnalysisOverride requires an engineering property target.".into())); }
let layer = target.layer();
let element = p.elements.get_mut(case).ok_or_else(missing)?;
if element.layer() != layer { return Err(ModelError("Case and property must share a layer.".into())); }
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
return Err(ModelError("ClearAnalysisOverride requires an analysis case.".into()));
};
overrides.remove(property);
Some(*case)
}
Command::CreateRelation(value) => {
vacant(p, value.id)?;
p.relations.insert(value.id, value.clone());
@@ -835,6 +891,10 @@ fn edit_header(
assign(&mut p.name, &mut p.description, &mut p.alias);
return Ok(());
}
if let Some(v) = p.definitions.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
}
if let Some(v) = p.elements.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
@@ -878,8 +938,13 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
return Ok(());
}
vacant(p, new)?;
crate::analysis::references::check_remap(p, old, new)
.map_err(|error| ModelError(error.to_string()))?;
if p.id == old {
p.id = new;
} else if let Some(mut v) = p.definitions.remove(&old) {
v.id = new;
p.definitions.insert(new, v);
} else if let Some(mut v) = p.elements.remove(&old) {
v.id = new;
p.elements.insert(new, v);
@@ -904,7 +969,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
}
}
}
crate::references::remap_references(p, old, new);
crate::references::remap_references(p, old, new)?;
Ok(())
}
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
+10
View File
@@ -449,6 +449,13 @@ pub fn plan_transition(
request: &TransitionRequest,
) -> Result<TransitionPlan, ModelError> {
validate_candidate(project)?;
let is_analysis = |id: &Id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis());
if request.sources.iter().any(is_analysis)
|| request.mappings.iter().any(|mapping| is_analysis(&mapping.source)
|| mapping.target.as_ref().is_some_and(is_analysis)
|| mapping.target_kind.is_some_and(Kind::is_analysis)) {
return Err(ModelError("Analysis transition semantics are not implemented; copying or reviewing only record headers is refused.".into()));
}
let mut plan = TransitionPlan {
base_fingerprint: project_fingerprint(project),
request: request.clone(),
@@ -608,6 +615,9 @@ pub fn plan_transition(
}
}
}
if selected.iter().any(is_analysis) {
return Err(ModelError("Transition dependency closure includes analysis records; explicit analysis transition semantics are required.".into()));
}
selected.retain(|id| {
if omitted.contains(id) {
if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); }
+12
View File
@@ -180,6 +180,18 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
}
pub fn validate(p: &Project) -> Vec<Issue> {
// Bound analysis trees before architectural visitors can clone an element.
// Admission itself never enters this public validator recursively.
if let Err(failure) = crate::analysis::admit_structure_checked(p, crate::analysis::AdmissionLimits::REFERENCE) {
let mut out = Vec::new();
error(&mut out, failure.object, &failure.code, &failure.field, failure.message);
return out;
}
validate_structure(p)
}
/// Architecture/header validation without recursively re-entering analysis admission.
pub(crate) fn validate_structure(p: &Project) -> Vec<Issue> {
let mut out = Vec::new();
let coverage = coverage::Index::new(p);
let topology_index = crate::topology_context::TopologyIndex::new(p);
+1 -1
View File
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
assert_eq!(session.project(), &after);
let fresh = Id::new_v4();
let mut reidentified = after.clone();
references::remap_references(&mut reidentified, f.r1, fresh);
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
let mut e = reidentified.elements.remove(&f.r1).unwrap();
e.id = fresh;
reidentified.elements.insert(fresh, e);
+97 -8
View File
@@ -13,6 +13,7 @@ mod transition_context_tests;
mod view_copy_tests;
use crate::forms::Draft;
use crate::{
behavior,
document::Document,
source_workspace::{self, LIMITS, Workspace},
};
@@ -849,6 +850,7 @@ impl App {
}
fn execute(&mut self, command: Command) -> bool {
let command = crate::port_edit::repair_owner_change(self.project(), command);
let created = self.behavior.creation.created_by(self.project(), &command);
let scenario_selection = self
.behavior
.scenario
@@ -856,7 +858,16 @@ impl App {
.or_else(|| self.behavior.topology.created_selection(&command));
match self.document.execute(command) {
Ok(selected) => {
if let Some(id) = scenario_selection.or(selected) {
if let Some(created) = created {
self.layer = created.layer;
self.selected = created.behavior;
self.selected_node = None;
self.behavior.active_owner = Some(created.behavior);
self.behavior.diagram = behavior::state_view::State::default();
self.behavior.diagram.diagram = created.diagram;
self.page = Page::Behavior;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
} else if let Some(id) = scenario_selection.or(selected) {
self.selected = id;
}
self.sync();
@@ -1019,6 +1030,9 @@ impl App {
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
architecture_authoring::subscription(self),
behavior::creation::subscription(self.behavior.creation.has_pending())
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
@@ -1076,9 +1090,7 @@ impl App {
// navigation and close retain raw input until explicit completion/cancel.
if self.architecture.is_some() {
if matches!(message, Message::Escape) {
self.architecture = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::none();
return self.architecture_event(architecture_authoring::Event::Cancel);
}
let placing = self.architecture.as_ref().is_some_and(|s| s.saved.placing);
if !matches!(
@@ -1104,6 +1116,7 @@ impl App {
Message::ScenarioCanvas(
crate::behavior::scenario::CanvasEvent::Scrolled(..)
| crate::behavior::scenario::CanvasEvent::Restored(..)
| crate::behavior::scenario::CanvasEvent::Viewport(..)
) | Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::Scrolled(..)
| crate::behavior::topology::CanvasEvent::Viewport(..)
@@ -1295,6 +1308,11 @@ impl App {
.topology
.enter(context.document, context.owner);
}
if let Some(context) = self.scenario_context() {
self.behavior
.scenario
.enter(context.document, context.scenario);
}
if self.connection.as_ref().is_some_and(|session| {
Some(session.context) != self.route_context()
|| self.page != Page::Diagram
@@ -1365,6 +1383,17 @@ impl App {
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
if let CanvasEvent::Viewport(_, size) = event {
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.revision == context.revision
}) && self.behavior.scenario.viewport(size)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if let CanvasEvent::Restored(_, token, area, offset) = event {
if self.scenario_context().is_some_and(|current| {
current.document == context.document && current.scenario == context.scenario
@@ -1378,7 +1407,7 @@ impl App {
return Task::none();
}
if let CanvasEvent::Scrolled(_, area, offset) = event {
// Inner and outer scroll notifications may come from the
// Canvas and outline 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| {
@@ -1395,6 +1424,19 @@ impl App {
return Task::none();
}
match event {
CanvasEvent::Focus(_) => {
self.behavior.scenario.toggle_focus();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Shelf(_, shelf) => {
self.behavior.scenario.toggle_shelf(shelf);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, scale) => {
if self.behavior.scenario.zoom(scale) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::OpenUse(_, occurrence) => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
@@ -1406,6 +1448,9 @@ impl App {
self.behavior.scenario = state;
match target {
Ok(target) => {
// A command can preselect this target while the
// caller remains visible, bypassing select below.
self.behavior.active_owner = Some(target);
self.select(target);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
@@ -1424,12 +1469,23 @@ impl App {
let caller = state.back(self.project(), context);
self.behavior.scenario = state;
if let Some(caller) = caller {
// A command may already have selected this UUID while
// leaving the referenced definition open. select's
// same-UUID fast path must not keep that old owner.
self.behavior.active_owner = Some(caller.owner);
self.select(caller.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(context) = self.scenario_context() {
self.behavior.scenario.restore_caller(context, caller);
}
} else {
// An edit can select a retargeted caller outside the
// definition that remains visible after rejected Back.
if crate::behavior::selected_behavior(self.project(), self.selected)
!= Some(context.scenario)
{
self.select(context.scenario);
}
self.status =
"The calling scenario reference is no longer available.".into();
}
@@ -1444,7 +1500,11 @@ impl App {
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status =
"Apply or cancel pending edits before choosing a scenario tool."
.into();
return Task::none();
}
self.behavior.scenario.choose_tool(context.scenario, tool);
@@ -1694,6 +1754,10 @@ impl App {
return self.update(Message::Add(child));
}
Message::Escape => {
if self.behavior.creation.has_pending() {
self.behavior.creation.acknowledge_commit();
self.status = "Behavior creation cancelled".into();
}
if self.connection.take().is_some() {
self.status = "Drawing cancelled · Select / move is active".into();
}
@@ -2237,7 +2301,28 @@ impl App {
}
Message::InspectorTab(tab) => self.inspector_tab = tab,
Message::Behavior(event) => {
if event.starts_form() && !self.behavior.has_pending() && !self.apply() {
if matches!(&event, behavior::Event::Creation(behavior::creation::Event::Open(layer)) if *layer != self.layer)
{
self.status =
"The active layer changed. Choose New in the current layer.".into();
return Task::none();
}
let creating = matches!(
&event,
behavior::Event::Creation(behavior::creation::Event::Open(..))
);
let focus_name = matches!(
&event,
behavior::Event::Creation(behavior::creation::Event::Choose(..))
);
if creating
&& (self.behavior.has_pending() || self.source_dirty() || self.draft_dirty())
{
self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into();
return Task::none();
}
if !creating && event.starts_form() && !self.behavior.has_pending() && !self.apply()
{
return Task::none();
}
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
@@ -2256,7 +2341,11 @@ impl App {
}
}
}
Ok(None) => {}
Ok(None) => {
if focus_name {
return iced::widget::operation::focus(behavior::creation::NAME_INPUT);
}
}
Err(error) => self.status = error,
}
}
@@ -3,6 +3,8 @@ use super::*;
use iced::widget::{button, column, pick_list, row, scrollable, text, text_input};
use serde::{Deserialize, Serialize};
mod palette;
#[cfg(test)]
mod tests;
pub use palette::view as palette;
#[derive(Debug, Clone)]
@@ -21,9 +23,35 @@ pub enum Event {
Filter(Filter),
ShowPlaced(bool),
Page(usize),
Edit,
CapturedEscape,
Cancel,
}
pub fn subscription(app: &App) -> Subscription<Message> {
if app.architecture.as_ref().is_some_and(State::inline) {
iced::event::listen_with(captured_escape)
} else {
Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<Message> {
// A focused Iced text input captures Escape while dropping its focus. Only
// inline architecture naming opts into also cancelling that same keypress.
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(Message::Architecture(Event::CapturedEscape))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Filter {
@@ -336,9 +364,15 @@ impl App {
}
return iced::widget::operation::focus("architecture-name");
}
Event::CapturedEscape => {
if self.architecture.as_ref().is_some_and(State::inline) {
return self.architecture_event(Event::Cancel);
}
}
Event::Cancel => {
self.architecture = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Cancelled · Select / move".into();
}
Event::Name(value) => {
if value.len() <= 16384
@@ -375,13 +409,6 @@ impl App {
s.saved.page = page.min(1_000_000);
}
}
Event::Edit => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(s) = &mut self.architecture {
s.saved.placing = false;
}
return iced::widget::operation::focus("architecture-name");
}
Event::Pick(id) => {
if !candidates(self)
.iter()
@@ -560,9 +587,10 @@ pub fn inline_editor(app: &App) -> Option<Element<'_, Message>> {
row![
iced::widget::Space::new().width(x),
iced::widget::opaque(
iced::widget::container(name_editor(app))
iced::widget::container(scrollable(name_editor(app)))
.padding(10)
.width(width)
.max_height((size.height - y - 8.).max(0.))
.style(crate::style::card)
)
]
@@ -4,7 +4,7 @@ use iced::{
Point, Rectangle, Size, Theme, mouse,
widget::{
canvas::{self, Frame, Geometry, Path, Stroke},
column, container, tooltip,
container, tooltip,
},
};
@@ -165,7 +165,7 @@ fn tool<'a>(
.into()
}
pub fn view(app: &App) -> Element<'_, Message> {
let mut content = column![tool(
let mut content = iced::widget::column![tool(
app,
Icon::Collapse(app.architecture_palette_expanded),
if app.architecture_palette_expanded {
@@ -0,0 +1,378 @@
use super::*;
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
app.layer = layer;
app.active_diagram = None;
let _ = app.update(Message::AddDiagram);
app.zoom = 0.75;
app.pan = iced::Vector::new(37., -19.);
app
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Architecture(event));
}
fn create(app: &mut App, kind: Kind, name: &str) -> Id {
send(app, Event::Start(kind, Some(Position { x: 120., y: 160. })));
send(app, Event::Name(name.into()));
send(app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
app.selected
}
#[test]
fn architecture_allocated_work_is_root_owned_and_one_exact_history_change() {
for (layer, participant, work) in [
(Layer::Oa, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::Function),
] {
let mut app = setup(layer);
let performer = create(&mut app, participant, "Control team");
let before = app.document.bundle().clone();
let camera = (app.zoom, app.pan);
let point = Position { x: 151., y: 221. };
send(&mut app, Event::Allocate(work, performer, Some(point)));
send(&mut app, Event::Name(" Report 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
let created = app.selected;
let element = &app.project().elements[&created];
assert_eq!(element.kind(), work);
assert_eq!(element.name, " Report 雨 ");
assert_eq!(element.parent, None);
assert!(app.project().relations.values().any(|r| {
r.kind() == RelationKind::Allocation && r.source() == performer && r.target() == created
}));
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
assert_eq!(d.nodes[&app.selected_node.unwrap()].position, point);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
}
#[test]
fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
use iced::widget::canvas::{Event as CanvasEvent, Program};
use iced::{keyboard, mouse};
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Entity, "Original team");
let before = app.document.bundle().clone();
let diagrams = app.project().diagrams.clone();
let camera = (app.zoom, app.pan);
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
let canvas = crate::diagram::DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
placing: false,
connection: None,
};
let mut state = crate::diagram::State::default();
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F2),
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
assert!(
canvas
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
.is_none()
);
let _ = canvas.update(
&mut state,
&CanvasEvent::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(iced::Point::new(150., 120.)),
);
let action = canvas
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
.unwrap();
let message = action.into_inner().0.unwrap();
let _ = app.update(message);
assert!(app.architecture.as_ref().unwrap().inline());
assert!(matches!(&app.architecture.as_ref().unwrap().saved.intent,
Intent::Rename { element: id, name } if *id == element && name == "Original team"));
send(&mut app, Event::Name(" Renamed team 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
assert_eq!(app.project().elements[&element].name, " Renamed team 雨 ");
assert_eq!(app.project().diagrams, diagrams);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn architecture_blank_names_and_escape_never_accept_placeholder_or_rename() {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Actor, "Dispatcher");
let before = app.document.bundle().clone();
let context = app.route_context().unwrap();
for event in [
Event::Start(Kind::Activity, Some(Position { x: 220., y: 330. })),
Event::Rename(context, element, Position { x: 150., y: 200. }),
] {
// Every cancel changes the gesture context; use the current context for F2.
let event = match event {
Event::Rename(_, element, point) => {
Event::Rename(app.route_context().unwrap(), element, point)
}
event => event,
};
send(&mut app, event);
send(&mut app, Event::Name(" \t ".into()));
send(&mut app, Event::Submit);
assert!(app.architecture.as_ref().unwrap().inline());
assert!(app.architecture.as_ref().unwrap().error.is_some());
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn architecture_invalid_click_cannot_poison_inline_or_checkpoint_geometry() {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
let before = app.document.bundle().clone();
let context = app.route_context().unwrap();
for point in [
Position { x: f32::NAN, y: 0. },
Position {
x: 0.,
y: f32::INFINITY,
},
] {
send(&mut app, Event::Click(context, point));
let draft = app.architecture.as_ref().unwrap();
assert!(draft.unnamed_tool());
assert!(validate_saved(app.project(), &draft.saved).is_ok());
assert_eq!(app.document.bundle(), &before);
}
send(&mut app, Event::Click(context, Position { x: 25., y: 30. }));
assert!(app.architecture.as_ref().unwrap().inline());
}
#[test]
fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() {
for rename in [false, true] {
for stale_document in [false, true] {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Entity, "Accepted team");
let point = Position {
x: 151.5,
y: 227.25,
};
let event = if rename {
Event::Rename(app.route_context().unwrap(), element, point)
} else {
Event::Start(Kind::Activity, Some(point))
};
send(&mut app, event);
send(
&mut app,
Event::Name(" Retained engineering name 雨 ".into()),
);
// Change only the binding. The accepted model/source must not be
// rewritten or a rejected draft silently rebound to the new context.
if stale_document {
app.architecture.as_mut().unwrap().document = Id::new_v4();
} else {
app.architecture.as_mut().unwrap().revision = app.document.revision() + 1;
}
let draft = app.architecture.as_ref().unwrap();
let saved = serde_json::to_value(&draft.saved).unwrap();
let binding = (draft.document, draft.revision);
let model = app.project().clone();
let source = app.document.bundle().clone();
let revision = app.document.revision();
let camera = (app.zoom, app.pan);
for _ in 0..2 {
send(&mut app, Event::Submit);
let draft = app.architecture.as_ref().unwrap();
assert!(
draft.inline(),
"rename={rename}, stale_document={stale_document}"
);
assert!(inline_editor(&app).is_some());
assert_eq!(draft.error.as_deref(), Some(app.status.as_str()));
assert!(app.status.contains("cancel and restart"));
assert_eq!(serde_json::to_value(&draft.saved).unwrap(), saved);
assert_eq!((draft.document, draft.revision), binding);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.zoom, app.pan), camera);
}
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
let fresh = create(&mut app, Kind::Actor, "Fresh context");
assert_eq!(app.project().elements[&fresh].name, "Fresh context");
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
}
}
}
fn key_event(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard;
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modified_key: keyboard::Key::Named(named),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn focused_text_input_escape(name: &str) -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
let mut field: iced::Element<'_, (), iced::Theme, ()> = text_input("Engineering name", name)
.id("architecture-name")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(field.as_widget());
let size = iced::Size::new(300., 40.);
let node =
field
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let escape = key_event(iced::keyboard::key::Named::Escape);
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
field.as_widget_mut().update(
&mut tree,
&escape,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(
shell.is_event_captured(),
"Iced consumes focused-input Escape"
);
assert!(
!tree
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
);
captured_escape(
escape,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap()
}
#[test]
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
for rename in [false, true] {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Actor, "Accepted dispatcher");
let model = app.project().clone();
let source = app.document.bundle().clone();
let revision = app.document.revision();
let camera = (app.zoom, app.pan);
let point = Position { x: 439., y: 328.2 };
let event = if rename {
Event::Rename(app.route_context().unwrap(), element, point)
} else {
Event::Start(Kind::Actor, Some(point))
};
send(&mut app, event);
let name = " Unaccepted long engineering name 雨 ";
send(&mut app, Event::Name(name.into()));
assert!(app.architecture.as_ref().unwrap().inline());
let message = focused_text_input_escape(name);
let _ = app.update(message);
assert!(app.architecture.is_none());
assert!(inline_editor(&app).is_none());
assert_eq!(app.status, "Cancelled · Select / move");
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.zoom, app.pan), camera);
}
}
#[test]
fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
let mut app = setup(Layer::Oa);
for event in [
None,
Some(Event::Start(Kind::Entity, None)),
Some(Event::Existing(None)),
] {
send(&mut app, Event::Cancel);
if let Some(event) = event {
send(&mut app, event);
}
let saved = app
.architecture
.as_ref()
.map(|s| serde_json::to_value(&s.saved).unwrap());
let before = app.document.bundle().clone();
let status = app.status.clone();
let epoch = app.canvas_epoch;
let message = focused_text_input_escape("Other field or existing-element search");
let _ = app.update(message);
assert_eq!(
app.architecture
.as_ref()
.map(|s| serde_json::to_value(&s.saved).unwrap()),
saved
);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.status, status);
assert_eq!(app.canvas_epoch, epoch);
}
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Escape),
iced::event::Status::Ignored,
iced::window::Id::unique(),
)
.is_none()
);
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Enter),
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.is_none()
);
}
+192 -2
View File
@@ -173,7 +173,12 @@ impl App {
self.behavior = restored.behavior;
self.workflow = restored.workflow;
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
if let Some(layer) = self.project().layer_of(focus) {
if let Some(layer) = self
.behavior
.creation
.pending_layer()
.or_else(|| self.project().layer_of(focus))
{
self.layer = layer;
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
self.active_diagram = self.diagram_for(layer, focus);
@@ -202,7 +207,21 @@ impl App {
} else {
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into()
};
self.request_lint()
let lint = self.request_lint();
if self
.architecture
.as_ref()
.is_some_and(architecture_authoring::State::inline)
{
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::creation::NAME_INPUT),
])
} else {
lint
}
}
}
@@ -331,6 +350,177 @@ mod tests {
.unwrap()
}
async fn run_recovery_tasks(app: &mut App, task: Task<Message>) -> (usize, bool, bool) {
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
// Mount real inputs, with focus initially in another control. This
// verifies the returned operation targets the naming field by its ID.
let mut fields: iced::Element<'_, (), iced::Theme, ()> = iced::widget::column![
iced::widget::text_input("Engineering name", "Retained name")
.id("architecture-name")
.on_input(|_| ()),
iced::widget::text_input("Other control", "Other value")
.id("unrelated-field")
.on_input(|_| ())
]
.into();
let mut tree = widget::Tree::new(fields.as_widget());
let node = fields.as_widget_mut().layout(
&mut tree,
&(),
&layout::Limits::new(iced::Size::ZERO, iced::Size::new(320., 100.)),
);
tree.children[1]
.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let mut operations = 0;
let mut lint_completions = 0;
let mut stream = iced_runtime::task::into_stream(task).expect("Recovery must still lint");
while let Some(action) = stream.next().await {
match action {
iced_runtime::Action::Widget(mut operation) => {
operations += 1;
fields.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&(),
operation.as_mut(),
);
}
iced_runtime::Action::Output(message @ Message::Linted(_, _)) => {
lint_completions += 1;
let _ = app.update(message);
}
_ => panic!("Unexpected recovery task action"),
}
}
assert_eq!(
lint_completions, 1,
"Autofocus must not replace request_lint"
);
assert!(app.lint_running.is_none());
let focused = |index: usize| {
tree.children[index]
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
};
(operations, focused(0), focused(1))
}
fn architecture_recovery_app() -> App {
let mut app = App::with_project(Project::empty());
let _ = app.update(Message::AddDiagram);
let _ = app.update(Message::Architecture(architecture_authoring::Event::Start(
Kind::Entity,
Some(Position { x: 80., y: 90. }),
)));
let _ = app.update(Message::Architecture(architecture_authoring::Event::Name(
"Accepted team".into(),
)));
let _ = app.update(Message::Architecture(architecture_authoring::Event::Submit));
assert!(app.architecture.is_none());
app
}
#[tokio::test]
async fn checkpoint_inline_create_and_rename_restore_focus_without_changing_drafts_or_history()
{
use architecture_authoring::Event;
for rename in [false, true] {
let mut app = architecture_recovery_app();
let point = Position { x: 439., y: 328.2 };
let event = if rename {
Event::Rename(app.route_context().unwrap(), app.selected, point)
} else {
Event::Start(Kind::Actor, Some(point))
};
let _ = app.update(Message::Architecture(event));
let _ = app.update(Message::Architecture(Event::Name(
" Recovered engineering name 雨 ".into(),
)));
let saved = serde_json::to_value(&app.architecture.as_ref().unwrap().saved).unwrap();
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
let expected = snapshot.clone().restore().unwrap();
let checkpoint = snapshot.id;
let mut recovered = App::with_project(Project::empty());
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert_eq!(
run_recovery_tasks(&mut recovered, task).await,
(1, true, false)
);
assert_eq!(recovered.page, Page::Diagram);
assert!(recovered.architecture.as_ref().unwrap().inline());
assert_eq!(
serde_json::to_value(&recovered.architecture.as_ref().unwrap().saved).unwrap(),
saved
);
assert_eq!(recovered.project(), expected.document.project());
assert_eq!(recovered.document.bundle(), expected.document.bundle());
assert_eq!(recovered.document.revision(), expected.document.revision());
assert_eq!(recovered.document.can_undo(), expected.document.can_undo());
assert_eq!(recovered.document.can_redo(), expected.document.can_redo());
assert_eq!(
recovered.source_workspace.bundle(),
expected.workspace.bundle()
);
assert_eq!(recovered.recovery.restored, Some(checkpoint));
assert!(recovered.path.is_none());
}
}
#[tokio::test]
async fn checkpoint_noninline_contexts_keep_other_focus_and_still_lint() {
use architecture_authoring::Event;
for context in ["accepted", "armed", "picker", "property", "source"] {
let mut app = architecture_recovery_app();
match context {
"armed" => {
let _ = app.update(Message::Architecture(Event::Start(Kind::Actor, None)));
}
"picker" => {
let _ = app.update(Message::Architecture(Event::Existing(None)));
}
"property" => {
app.draft
.as_mut()
.unwrap()
.input("/name", "Unapplied property".into());
assert!(app.draft_dirty());
}
"source" => {
app.source_workspace
.edit("incomplete source 雨".into(), app.source_editor.cursor());
assert!(app.source_dirty());
}
_ => {}
}
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
let expected = snapshot.clone().restore().unwrap();
let mut recovered = App::with_project(Project::empty());
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert_eq!(
run_recovery_tasks(&mut recovered, task).await,
(0, false, true),
"{context}"
);
assert!(
!recovered
.architecture
.as_ref()
.is_some_and(architecture_authoring::State::inline)
);
assert_eq!(recovered.project(), expected.document.project());
assert_eq!(recovered.document.bundle(), expected.document.bundle());
assert_eq!(recovered.document.revision(), expected.document.revision());
assert_eq!(
recovered.source_workspace.bundle(),
expected.workspace.bundle()
);
}
}
#[test]
fn saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work() {
let directory = tempfile::tempdir().unwrap();
+55 -17
View File
@@ -14,7 +14,11 @@ use iced::{
};
use oa_core::*;
use std::collections::BTreeMap;
pub(crate) mod creation;
pub(crate) mod scenario;
// Projection acceptance precedes the coordinated scenario renderer integration.
#[cfg(test)]
mod scenario_lifecycle;
pub(crate) mod state_canvas;
pub(crate) mod state_edit;
pub(crate) mod state_menu;
@@ -29,12 +33,12 @@ mod scenario_scroll {
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, renderer, widget};
use iced::{Length, Vector, overlay};
pub(super) fn observe<'a>(
pub(super) fn observe<'a, R: renderer::Renderer + 'a>(
context: scenario::Context,
area: scenario::ScrollArea,
restore: Option<scenario::ScrollRestore>,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
content: impl Into<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
Element::new(Observer {
content: content.into(),
context,
@@ -731,6 +735,7 @@ mod scenario_scroll {
}
#[derive(Debug, Clone)]
pub enum Event {
Creation(creation::Event),
Topology(topology::Event),
Scenario(scenario::Event),
States(states::Event),
@@ -740,7 +745,8 @@ impl Event {
pub fn starts_form(&self) -> bool {
matches!(
self,
Self::Topology(topology::Event::Start(..))
Self::Creation(creation::Event::Open(..))
| Self::Topology(topology::Event::Start(..))
| Self::Scenario(scenario::Event::Start(..))
| Self::States(states::Event::Start(..) | states::Event::Edit(..))
)
@@ -749,6 +755,7 @@ impl Event {
matches!(
self,
Self::Submit
| Self::Creation(creation::Event::Submit)
| Self::Topology(topology::Event::Submit)
| Self::Scenario(scenario::Event::Submit)
| Self::States(states::Event::Submit)
@@ -757,6 +764,7 @@ impl Event {
}
#[derive(Default)]
pub struct State {
pub(crate) creation: creation::State,
pub active_owner: Option<Id>,
pub diagram: state_view::State,
pub(crate) topology: topology::State,
@@ -773,6 +781,7 @@ struct RecoveryDraft {
}
#[derive(serde::Serialize, serde::Deserialize)]
enum RecoveryKind {
Creation,
State,
Scenario,
Process,
@@ -782,6 +791,7 @@ impl State {
/// viewport geometry and document/revision tokens never cross a restart.
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
let drafts = [
(RecoveryKind::Creation, self.creation.recovery_snapshot()?),
(RecoveryKind::State, self.states.recovery_snapshot()?),
(RecoveryKind::Scenario, self.scenario.recovery_snapshot()?),
(RecoveryKind::Process, self.topology.recovery_snapshot()?),
@@ -817,6 +827,9 @@ impl State {
}
let mut restored = Self::default();
match saved.kind {
RecoveryKind::Creation => {
restored.creation = creation::State::from_recovery(saved.draft)?
}
RecoveryKind::State => restored.states = states::State::from_recovery(saved.draft)?,
RecoveryKind::Scenario => {
restored.scenario = scenario::State::from_recovery(saved.draft)?
@@ -831,7 +844,7 @@ impl State {
}
pub fn has_pending(&self) -> bool {
self.pending_owner().is_some()
self.creation.has_pending() || self.pending_owner().is_some()
}
pub fn pending_owner(&self) -> Option<Id> {
@@ -844,7 +857,9 @@ impl State {
/// Prepare the active form without discarding inputs. The caller must execute
/// this exact command and acknowledge only a successful document transaction.
pub fn prepare_pending(&self, p: &Project) -> Result<Option<Command>, String> {
if self.topology.pending_owner().is_some() {
if self.creation.has_pending() {
self.creation.prepare_pending(p).map(Some)
} else if self.topology.pending_owner().is_some() {
self.topology.prepare_pending(p).map(Some)
} else if self.scenario.pending_owner().is_some() {
self.scenario.prepare_pending(p).map(Some)
@@ -858,6 +873,7 @@ impl State {
/// Clear the active form after its prepared command commits successfully.
/// Rejected commands must leave the form available for repair and retry.
pub fn acknowledge_commit(&mut self) {
self.creation.acknowledge_commit();
self.topology.acknowledge_commit();
self.scenario.acknowledge_commit();
self.states.acknowledge_commit();
@@ -867,7 +883,8 @@ impl State {
if self.has_pending()
&& matches!(
&event,
Event::Topology(topology::Event::Start(..))
Event::Creation(creation::Event::Open(..))
| Event::Topology(topology::Event::Start(..))
| Event::Scenario(scenario::Event::Start(..))
| Event::States(states::Event::Start(..) | states::Event::Edit(..))
)
@@ -885,6 +902,7 @@ impl State {
return Ok(None);
}
match event {
Event::Creation(event) => self.creation.update(p, event),
Event::Topology(event) => self.topology.update(p, event),
Event::Scenario(event) => self.scenario.update(p, event),
Event::States(event) => self.states.update(p, event),
@@ -898,6 +916,9 @@ impl State {
pub fn view(app: &App) -> Element<'_, Message> {
let p = app.project();
if let Some(card) = app.behavior.creation.view(p) {
return card.map(|event| Message::Behavior(Event::Creation(event)));
}
let choices = p
.elements
.values()
@@ -916,23 +937,40 @@ pub fn view(app: &App) -> Element<'_, Message> {
.cloned();
let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14);
let Some(selected) = selected else {
return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into();
return out
.push(label(
"Start a named process, scenario or state machine, then model its behavior here.",
))
.push(action(
"New",
Message::Behavior(Event::Creation(creation::Event::Open(app.layer))),
))
.padding(24)
.into();
};
let e = &p.elements[&selected.id];
let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain);
let compact_canvas = matches!(
e.kind(),
Kind::Process | Kind::FunctionalChain | Kind::Scenario
);
if e.kind() == Kind::StateMachine {
out = column![text("States & modes").size(20)].spacing(8);
} else if compact_process {
// The process canvas owns its compact header and remaining-height layout.
} else if compact_canvas {
// The process/scenario canvas owns its compact remaining-height layout.
out = column![].spacing(6);
} else if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
out = column![text("Processes & functional chains").size(20)].spacing(8);
}
out = out.push(
pick_list(choices, Some(selected), Message::BehaviorOwner)
.width(Fill)
.text_size(13)
.padding(if compact_process { 6 } else { 10 }),
row![
pick_list(choices, Some(selected), Message::BehaviorOwner)
.width(Fill)
.text_size(13)
.padding(if compact_canvas { 6 } else { 10 }),
action(
"New",
Message::Behavior(Event::Creation(creation::Event::Open(app.layer)))
)
]
.spacing(8),
);
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
return topology::view(app, e.id, out);
+665
View File
@@ -0,0 +1,665 @@
//! Local behavior creation. A draft is not a model object: only its validated
//! batch may cross Document::execute, and acknowledgement follows success.
use iced::{
Element as UiElement, Fill,
widget::{button, checkbox, column, container, row, scrollable, text, text_input},
};
use oa_core::*;
use serde::{Deserialize, Serialize};
const PAGE_SIZE: usize = 6;
pub const NAME_INPUT: &str = "behavior-creation-name";
// A focused text input consumes Escape. Preserve the application's modal
// priority by emitting its ordinary Escape message from this narrow bridge.
pub fn subscription(active: bool) -> iced::Subscription<()> {
if active {
iced::event::listen_with(captured_escape)
} else {
iced::Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<()> {
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Family {
Sequence,
Scenario,
Machine,
}
impl Family {
fn kind(self, layer: Layer) -> Kind {
match (self, layer) {
(Self::Sequence, Layer::Oa) => Kind::Process,
(Self::Sequence, Layer::Sa) => Kind::FunctionalChain,
(Self::Scenario, _) => Kind::Scenario,
(Self::Machine, _) => Kind::StateMachine,
}
}
fn title(self, layer: Layer) -> &'static str {
match (self, layer) {
(Self::Sequence, Layer::Oa) => "Operational process",
(Self::Sequence, Layer::Sa) => "Functional chain",
(Self::Scenario, _) => "Scenario",
(Self::Machine, _) => "State machine",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Picker {
Owner,
Context,
}
impl Picker {
fn index(self) -> usize {
match self {
Self::Owner => 0,
Self::Context => 1,
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
Open(Layer),
Choose(Family),
Name(String),
Owner(Id),
Viewpoint(ScenarioViewpoint),
Context(Option<Id>),
ContextOptions(bool),
FirstView(bool),
Search(Picker, String),
Page(Picker, usize),
Cancel,
Submit,
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Browser {
query: String,
page: usize,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
version: u32,
document: Id,
layer: Layer,
family: Option<Family>,
name: String,
owner: Option<Id>,
viewpoint: Option<ScenarioViewpoint>,
context: Option<Id>,
#[serde(default)]
context_open: bool,
first_view: bool,
element: Id,
diagram: Id,
placement: Id,
browsers: [Browser; 2],
}
#[derive(Default)]
pub struct State {
draft: Option<Draft>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Created {
pub behavior: Id,
pub layer: Layer,
pub diagram: Option<Id>,
}
impl State {
pub fn has_pending(&self) -> bool {
self.draft.is_some()
}
pub fn pending_layer(&self) -> Option<Layer> {
self.draft.as_ref().map(|d| d.layer)
}
/// Capture this before successful acknowledgement; never select these IDs
/// on mere planning or a rejected source/model transaction.
pub fn created(&self) -> Option<Created> {
let d = self.draft.as_ref()?;
d.family.map(|family| Created {
behavior: d.element,
layer: d.layer,
diagram: (family == Family::Machine && d.first_view).then_some(d.diagram),
})
}
/// Match before execution, then apply this target only after success.
/// Identity alone is insufficient: changed bodies or trailing operations
/// must not inherit the prepared creation's selection/opening contract.
pub fn created_by(&self, p: &Project, command: &Command) -> Option<Created> {
let target = self.created()?;
(self.prepare_pending(p).ok().as_ref() == Some(command)).then_some(target)
}
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
pub fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft: Draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
if draft.version != 1 {
return Err("Unsupported behavior creation draft version.".into());
}
// Raw values and stale references remain editable; planning validates
// against the current document rather than silently fixing the draft.
Ok(Self { draft: Some(draft) })
}
pub fn acknowledge_commit(&mut self) {
self.draft = None;
}
pub fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
self.draft
.as_ref()
.ok_or("Choose New to start a behavior.")?
.command(p)
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
match event {
Event::Cancel => {
self.draft = None;
return Ok(None);
}
Event::Open(layer) => {
if self.has_pending() {
return Err("Create or cancel the pending behavior first.".into());
}
self.draft = Some(Draft {
version: 1,
document: p.id,
layer,
family: None,
name: String::new(),
owner: None,
viewpoint: None,
context: None,
context_open: false,
first_view: false,
element: Id::new_v4(),
diagram: Id::new_v4(),
placement: Id::new_v4(),
browsers: Default::default(),
});
return Ok(None);
}
Event::Submit => return self.prepare_pending(p).map(Some),
_ => {}
}
let d = self
.draft
.as_mut()
.ok_or("Choose New to start a behavior.")?;
if d.document != p.id {
return Err("This draft belongs to another document. Cancel it to start here.".into());
}
match event {
Event::Choose(family) => {
if d.family.is_some_and(|old| old != family) {
return Err(
"Cancel this draft before choosing a different behavior family.".into(),
);
}
d.family = Some(family);
}
Event::Name(name) => d.name = name,
Event::Owner(id) if d.family == Some(Family::Machine) => {
if !eligible(p, d.layer, Picker::Owner, id) {
return Err("Choose an existing participant in this layer.".into());
}
d.owner = Some(id);
}
Event::Viewpoint(viewpoint) if d.family == Some(Family::Scenario) => {
if !viewpoints(d.layer).contains(&viewpoint) {
return Err("Choose a scenario viewpoint from this layer.".into());
}
d.viewpoint = Some(viewpoint);
}
Event::Context(id) if d.family == Some(Family::Scenario) => {
if id.is_some_and(|id| !eligible(p, d.layer, Picker::Context, id)) {
return Err("Choose an existing same-layer scenario context.".into());
}
d.context = id;
}
Event::ContextOptions(open) if d.family == Some(Family::Scenario) => {
d.context_open = open;
}
Event::FirstView(value) if d.family == Some(Family::Machine) => d.first_view = value,
Event::Search(picker, query) => d.browsers[picker.index()] = Browser { query, page: 0 },
Event::Page(picker, page) => d.browsers[picker.index()].page = page,
Event::Cancel | Event::Open(_) | Event::Submit => unreachable!(),
_ => return Err("That field does not apply to this behavior family.".into()),
}
Ok(None)
}
}
fn viewpoints(layer: Layer) -> [ScenarioViewpoint; 2] {
match layer {
Layer::Oa => [
ScenarioViewpoint::OperationalActivity,
ScenarioViewpoint::ParticipantInteraction,
],
Layer::Sa => [
ScenarioViewpoint::Functional,
ScenarioViewpoint::BoundaryInterface,
],
}
}
fn viewpoint_title(viewpoint: ScenarioViewpoint) -> &'static str {
match viewpoint {
ScenarioViewpoint::OperationalActivity => "Activities · operational exchanges",
ScenarioViewpoint::ParticipantInteraction => "Participants · interactions",
ScenarioViewpoint::Functional => "Functions · functional exchanges",
ScenarioViewpoint::BoundaryInterface => "System boundary · interface exchanges",
ScenarioViewpoint::MixedLegacy => "Legacy mixed viewpoint",
}
}
fn eligible(p: &Project, layer: Layer, picker: Picker, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.layer() == layer
&& match picker {
Picker::Owner => validation::parent_permits(e.kind(), Kind::StateMachine),
Picker::Context => {
e.kind().is_sequence()
|| e.kind().is_work()
|| e.kind().is_performer()
|| matches!(
e.kind(),
Kind::Capability
| Kind::SystemCapability
| Kind::OperationalMission
| Kind::Mission
)
}
}
})
}
fn choices<'a>(p: &'a Project, layer: Layer, picker: Picker, query: &str) -> Vec<&'a Element> {
let query = query.trim().to_lowercase();
let mut matches: Vec<_> = p
.elements
.values()
.filter(|e| eligible(p, layer, picker, e.id))
.filter(|e| query.is_empty() || reference_label(p, e.id).to_lowercase().contains(&query))
.collect();
matches.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then(a.id.cmp(&b.id))
});
matches
}
fn reference_context(p: &Project, e: &Element) -> String {
let mut names = Vec::new();
let mut parent = e.parent;
let mut seen = std::collections::BTreeSet::new();
while let Some(id) = parent {
if !seen.insert(id) {
break;
}
let Some(ancestor) = p.elements.get(&id) else {
break;
};
names.push(ancestor.name.as_str());
parent = ancestor.parent;
}
names.reverse();
let location = if names.is_empty() {
format!("{} root", e.layer())
} else {
format!("{} / {}", e.layer(), names.join(" / "))
};
format!("{} · {} · {location}", e.kind(), e.alias)
}
fn reference_label(p: &Project, id: Id) -> String {
p.elements
.get(&id)
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
.unwrap_or_else(|| "Missing element — choose a replacement".into())
}
impl Draft {
fn command(&self, p: &Project) -> Result<Command, String> {
if self.document != p.id {
return Err("This draft belongs to another document.".into());
}
let family = self
.family
.ok_or("Choose the behavior you want to create.")?;
if self.name.trim().is_empty() {
return Err("Enter the behavior's name before creating it.".into());
}
if [self.element, self.diagram, self.placement]
.into_iter()
.any(|id| p.contains(id))
|| self.element == self.diagram
|| self.element == self.placement
|| self.diagram == self.placement
{
return Err(
"A reserved creation identity is already in use. Cancel and start a fresh draft."
.into(),
);
}
let kind = family.kind(self.layer);
let parent = if family == Family::Machine {
Some(
self.owner
.filter(|id| eligible(p, self.layer, Picker::Owner, *id))
.ok_or("Choose the participant that owns this state machine.")?,
)
} else {
if self.owner.is_some() {
return Err("Processes and scenarios belong at the layer root; context is a separate reference.".into());
}
None
};
if family != Family::Machine && self.first_view {
return Err("A state view can only accompany a state machine.".into());
}
let mut body = ElementBody::new(kind, self.layer)
.ok_or("This behavior is unavailable in this layer.")?;
if family == Family::Scenario {
let viewpoint = self
.viewpoint
.filter(|v| viewpoints(self.layer).contains(v))
.ok_or("Choose the scenario's viewpoint explicitly.")?;
if self
.context
.is_some_and(|id| !eligible(p, self.layer, Picker::Context, id))
{
return Err(
"The selected scenario context is missing or no longer compatible.".into(),
);
}
body = ElementBody::shared_in(
self.layer,
SharedBody::Scenario {
viewpoint,
context: self.context,
precondition: String::new(),
postcondition: String::new(),
steps: Vec::new(),
},
);
} else if self.viewpoint.is_some() || self.context.is_some() {
return Err(
"Scenario viewpoint and context cannot be applied to another behavior family."
.into(),
);
}
let element = Element {
id: self.element,
alias: p.fresh_alias(&self.name, "behavior", self.element),
name: self.name.clone(),
description: String::new(),
parent,
body,
};
let mut commands = vec![Command::CreateElement(element)];
if self.first_view {
let working = execute_batch(p, &commands).map_err(|e| e.to_string())?;
// Reuse the state editor's geometry contract. A new machine has one
// root placement; reserve both presentation IDs across retries.
let plan = super::state_edit::create_view(&working, self.element)
.map_err(|e| e.to_string())?;
let Command::Batch(mut view_commands) = plan.command else {
return Err("Unexpected state-view plan.".into());
};
if view_commands.len() != 2 {
return Err("Unexpected additional operation in the first state-view plan.".into());
}
let Some(Command::Layout(layout)) = view_commands.pop() else {
return Err("State-view plan must finish with its layout operation.".into());
};
let Some(Command::CreateDiagram(mut diagram)) = view_commands.pop() else {
return Err("State-view plan is missing its diagram.".into());
};
if diagram.id != layout || diagram.nodes.len() != 1 {
return Err("A first state view must contain only its new machine.".into());
}
let mut node = diagram
.nodes
.into_values()
.next()
.ok_or("State-view root is missing.")?;
if node.element != self.element {
return Err("State-view root differs from the new machine.".into());
}
diagram.id = self.diagram;
diagram.alias = working.fresh_alias(&diagram.name, "state_view", self.diagram);
node.id = self.placement;
node.alias = working.fresh_alias(
&format!("{} placement", self.name),
"placement",
self.placement,
);
diagram.nodes = [(node.id, node)].into_iter().collect();
commands.push(Command::CreateDiagram(diagram));
commands.push(Command::Layout(self.diagram));
}
let command = Command::Batch(commands);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
}
impl State {
/// Parent maps these local events into the existing guarded Behavior route.
/// The card's fields scroll; Create/Cancel stay outside that scroll region.
pub fn view<'a>(&'a self, p: &'a Project) -> Option<UiElement<'a, Event>> {
self.view_with(p)
}
fn view_with<'a, R: iced::advanced::text::Renderer + 'a>(
&'a self,
p: &'a Project,
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
let d = self.draft.as_ref()?;
let mut fields = column![].spacing(10);
let title = if let Some(family) = d.family {
fields = fields.push(
text_input("Exact behavior name", &d.name)
.id(NAME_INPUT)
.on_input(Event::Name)
.on_submit(Event::Submit)
.padding(8),
);
match family {
Family::Sequence => {
fields = fields.push(
text(format!(
"Owned by the {} layer root. Involve existing work after creation.",
d.layer
))
.size(12),
);
}
Family::Scenario => {
fields = fields.push(
text(format!(
"Owned by the {} layer root. Choose what the lifelines represent:",
d.layer
))
.size(12),
);
for viewpoint in viewpoints(d.layer) {
fields = fields.push(
button(text(viewpoint_title(viewpoint)).size(13))
.style(if d.viewpoint == Some(viewpoint) {
button::primary
} else {
button::secondary
})
.on_press(Event::Viewpoint(viewpoint))
.padding(7),
);
}
let context_label = if d.context_open {
"Hide optional context".into()
} else if let Some(id) = d.context {
format!("Context: {} · Change…", reference_label(p, id))
} else {
"Add optional context…".into()
};
fields = fields.push(
button(text(context_label).size(12))
.on_press(Event::ContextOptions(!d.context_open))
.padding(6),
);
if d.context_open {
fields = fields.push(picker_view(p, d, Picker::Context));
}
}
Family::Machine => {
fields = fields.push(picker_view(p, d, Picker::Owner));
fields = fields.push(
checkbox(d.first_view)
.label("Create the first state view")
.on_toggle(Event::FirstView),
);
fields = fields.push(text("The machine starts without regions or states. Add those explicitly in its workspace.").size(12));
}
}
format!("New {}", family.title(d.layer))
} else {
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
let purpose = match family {
Family::Sequence => "A path through existing work and exchanges",
Family::Scenario => "Ordered interactions from an explicit viewpoint",
Family::Machine => "States and modes of an existing participant",
};
fields = fields.push(
button(
column![text(family.title(d.layer)).size(15), text(purpose).size(12)]
.spacing(3),
)
.on_press(Event::Choose(family))
.padding(10)
.width(Fill),
);
}
format!("New behavior · {}", d.layer)
};
let mut actions =
row![button("Cancel · Esc").on_press(Event::Cancel).padding(8)].spacing(8);
if d.family.is_some() {
actions = actions.push(button("Create and open").on_press(Event::Submit).padding(8));
}
Some(
container(
column![
text(title).size(20),
scrollable(fields)
.id("behavior-creation-fields")
.height(Fill),
container(actions).id("behavior-creation-actions")
]
.spacing(12),
)
.padding(18)
.max_width(680)
.height(Fill)
.style(crate::style::card)
.into(),
)
}
}
fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
p: &'a Project,
d: &'a Draft,
picker: Picker,
) -> UiElement<'a, Event, iced::Theme, R> {
let browser = &d.browsers[picker.index()];
let options = choices(p, d.layer, picker, &browser.query);
let page = browser
.page
.min(options.len().saturating_sub(1) / PAGE_SIZE);
let selected = match picker {
Picker::Owner => d.owner,
Picker::Context => d.context,
};
let title = match picker {
Picker::Owner => "Owned by participant",
Picker::Context => "Scenario context · optional",
};
let mut out = column![
text(title).size(13),
text_input("Search names, aliases or parent context", &browser.query)
.on_input(move |query| Event::Search(picker, query))
.padding(6)
]
.spacing(5);
if let Some(id) = selected {
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
} else if picker == Picker::Context {
out = out.push(text("No context selected").size(12));
}
if options.is_empty() {
out = out.push(text(match picker {
Picker::Owner => "No matching participant. Refine the search, or cancel and create a participant in Architecture first.",
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
}).size(12));
}
for e in options.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
let message = match picker {
Picker::Owner => Event::Owner(e.id),
Picker::Context => Event::Context(Some(e.id)),
};
out = out.push(
container(
button(
column![
text(&e.name).size(13),
text(reference_context(p, e)).size(11)
]
.spacing(2),
)
.style(if selected == Some(e.id) {
button::primary
} else {
button::secondary
})
.on_press(message)
.padding(6)
.width(Fill),
)
.id(format!("behavior-creation-candidate-{}", e.id)),
);
}
let mut navigation = row![text(format!("{} matches", options.len())).size(11)].spacing(8);
if page > 0 {
navigation = navigation.push(button("Previous").on_press(Event::Page(picker, page - 1)));
}
if (page + 1) * PAGE_SIZE < options.len() {
navigation = navigation.push(button("Next").on_press(Event::Page(picker, page + 1)));
}
if picker == Picker::Context && selected.is_some() {
navigation = navigation.push(button("Clear context").on_press(Event::Context(None)));
}
out.push(navigation).into()
}
#[cfg(test)]
mod app_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,340 @@
//! App-boundary regressions for the coordinated parent wiring. No I/O task is
//! polled: file dialogs, checkpoint writes and lesson loading remain inactive.
use super::*;
use crate::app::{App, Message, Page, Pending};
use std::sync::Arc;
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(crate::behavior::Event::Creation(event)));
}
fn raw_property(app: &App) -> serde_json::Value {
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
}
fn begin(app: &mut App, layer: Layer, family: Family, name: &str) {
let _ = app.update(Message::Layer(layer));
let _ = app.update(Message::Page(Page::Behavior));
send(app, Event::Open(layer));
send(app, Event::Choose(family));
send(app, Event::Name(name.into()));
}
fn focused_escape() -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
use iced::keyboard::{self, key::Named};
let mut input: iced::Element<'_, (), iced::Theme, ()> = text_input("Name", "Unfinished name")
.id(NAME_INPUT)
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(input.as_widget());
let size = iced::Size::new(300., 40.);
let node =
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
input.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(shell.is_event_captured());
assert_eq!(
captured_escape(
event.clone(),
iced::event::Status::Ignored,
iced::window::Id::unique()
),
None
);
captured_escape(
event,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap();
Message::Escape
}
#[test]
fn app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation() {
for file in [false, true] {
let mut app = App::with_project(Project::empty());
begin(
&mut app,
Layer::Oa,
Family::Sequence,
"Keep while overlay closes",
);
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
if file {
app.file_prompt = Some(crate::app::FilePrompt {
operation: crate::app::FileOperation::Save,
path: "unsubmitted-name.arcadia".into(),
overwrite: false,
});
} else {
app.pending = Some(Pending::Walkthrough);
}
let _ = app.update(focused_escape());
assert!(app.file_prompt.is_none());
assert!(app.pending.is_none());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(focused_escape());
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
}
#[test]
fn app_creation_opens_the_actual_new_owner_instead_of_first_or_previously_active_behavior() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let old = p
.add_element(Family::Sequence.kind(layer), layer, "Old behavior", None)
.unwrap();
let mut app = App::with_project(p.clone());
let _ = app.update(Message::Select(old));
begin(&mut app, layer, Family::Sequence, " New exact path 雨 ");
let target = app.behavior.creation.created().unwrap();
assert_ne!(target.behavior, old);
let before = app.document.bundle().clone();
send(&mut app, Event::Submit);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.layer, layer);
assert_eq!(app.selected, target.behavior);
assert_eq!(app.behavior.active_owner, Some(target.behavior));
assert_eq!(crate::behavior::selected_owner(&app), Some(target.behavior));
assert_eq!(
app.project().elements[&target.behavior].name,
" New exact path 雨 "
);
assert!(!app.behavior.has_pending());
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
assert_eq!(app.project().elements[&old], p.elements[&old]);
}
}
#[test]
fn app_machine_behavior_submit_opens_its_opted_in_view_as_one_undoable_transaction() {
for first_view in [false, true] {
let mut p = Project::empty();
let owner = p
.add_element(Kind::System, Layer::Sa, "Flight system", None)
.unwrap();
let mut app = App::with_project(p.clone());
begin(&mut app, Layer::Sa, Family::Machine, " Flight modes 雨 ");
send(&mut app, Event::Owner(owner));
send(&mut app, Event::FirstView(first_view));
let target = app.behavior.creation.created().unwrap();
let before = app.document.bundle().clone();
let _ = app.update(Message::Behavior(crate::behavior::Event::Submit));
assert!(!app.behavior.has_pending(), "{}", app.status);
assert_eq!(app.selected, target.behavior);
assert_eq!(app.behavior.active_owner, Some(target.behavior));
assert_eq!(app.behavior.diagram.diagram, target.diagram);
assert_eq!(
crate::behavior::state_view::diagram(&app, target.behavior),
target.diagram
);
assert_eq!(app.project().elements[&target.behavior].parent, Some(owner));
assert_eq!(app.page, Page::Behavior);
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.project(), &p);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
}
}
#[test]
fn app_stale_layer_open_never_starts_or_replaces_a_draft() {
let mut app = App::with_project(Project::empty());
let _ = app.update(Message::Layer(Layer::Sa));
app.page = Page::Behavior;
let accepted = app.document.snapshot();
send(&mut app, Event::Open(Layer::Oa));
assert!(!app.behavior.has_pending());
assert_eq!(app.layer, Layer::Sa);
send(&mut app, Event::Open(Layer::Sa));
send(&mut app, Event::Choose(Family::Sequence));
send(&mut app, Event::Name("Retained system chain".into()));
let raw = app.behavior.recovery_snapshot().unwrap();
send(&mut app, Event::Open(Layer::Oa));
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn app_new_never_auto_applies_valid_or_invalid_property_and_source_drafts() {
for dirty in [
"property-valid",
"property-invalid",
"source-valid",
"source-invalid",
] {
let mut app = App::with_project(Project::empty());
app.page = Page::Behavior;
match dirty {
"property-valid" => app
.draft
.as_mut()
.unwrap()
.input("/name", "Raw property name".into()),
"property-invalid" => app.draft.as_mut().unwrap().input("/id", "-".into()),
"source-valid" => app.source_workspace.edit(
format!("{}\n// Retained comment\n", app.source_workspace.text()),
app.source_editor.cursor(),
),
_ => app
.source_workspace
.edit("unfinished source 雨".into(), app.source_editor.cursor()),
}
let accepted = app.document.snapshot();
let property = raw_property(&app);
let workspace = app.source_workspace.recovery_snapshot();
send(&mut app, Event::Open(Layer::Oa));
assert!(
!app.behavior.creation.has_pending(),
"{dirty}: New must refuse without applying another editor"
);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(raw_property(&app), property);
assert_eq!(
serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(),
serde_json::to_value(workspace).unwrap()
);
assert!(!app.document.can_undo());
}
}
#[test]
fn app_choosing_another_behavior_or_layer_cannot_commit_a_creation_draft() {
let mut p = Project::empty();
let old = p
.add_element(Kind::Process, Layer::Oa, "Old process", None)
.unwrap();
let mut app = App::with_project(p);
begin(&mut app, Layer::Oa, Family::Sequence, "Retain this process");
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
let _ = app.update(Message::Layer(Layer::Sa));
let _ = app.update(Message::BehaviorOwner(crate::app::Choice {
id: old,
label: "Old process".into(),
}));
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::Escape);
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
#[test]
fn app_lesson_request_cancel_retains_creation_then_escape_discards_only_the_draft() {
let mut app = App::with_project(Project::empty());
begin(
&mut app,
Layer::Oa,
Family::Sequence,
"Unfinished engineering path",
);
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
let task = app.update(Message::Home(crate::home::Event::Walkthrough));
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
assert!(iced_runtime::task::into_stream(task).is_none());
let _ = app.update(Message::Escape);
assert!(app.pending.is_none());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::Escape);
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn app_recovered_rootless_sa_creation_retains_layer_ids_and_other_raw_drafts() {
let mut app = App::with_project(Project::empty());
begin(
&mut app,
Layer::Sa,
Family::Scenario,
" Recovered system scenario 雨 ",
);
send(
&mut app,
Event::Viewpoint(ScenarioViewpoint::BoundaryInterface),
);
// Recovery can contain simultaneous drafts even though new input is guarded.
app.draft
.as_mut()
.unwrap()
.input("/name", "Retained property".into());
app.source_workspace.edit(
"unfinished source preserved".into(),
app.source_editor.cursor(),
);
let snapshot = crate::recovery::Snapshot::capture(
app.canvas_document,
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
None,
app.selected,
)
.unwrap();
let expected = snapshot.clone().restore().unwrap();
let raw = app.behavior.recovery_snapshot().unwrap();
let mut restored = App::with_project(Project::empty());
let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(snapshot))));
assert_eq!(restored.page, Page::Behavior);
assert_eq!(restored.layer, Layer::Sa);
assert_eq!(restored.behavior.recovery_snapshot().unwrap(), raw);
assert_eq!(
restored.behavior.creation.created(),
app.behavior.creation.created()
);
assert_eq!(restored.project(), expected.document.project());
assert_eq!(restored.document.bundle(), expected.document.bundle());
assert_eq!(
restored.source_workspace.bundle(),
expected.workspace.bundle()
);
assert_eq!(raw_property(&restored), raw_property(&app));
let before = restored.document.snapshot();
send(&mut restored, Event::Submit);
assert!(Arc::ptr_eq(&before, &restored.document.snapshot()));
assert_eq!(restored.behavior.recovery_snapshot().unwrap(), raw);
assert_eq!(raw_property(&restored), raw_property(&app));
}
@@ -0,0 +1,599 @@
use super::*;
use crate::document::Document;
use oa_core::source_project::ProjectLimits;
fn start(p: &Project, layer: Layer, family: Family, name: &str) -> State {
let mut state = State::default();
assert!(state.update(p, Event::Open(layer)).unwrap().is_none());
state.update(p, Event::Choose(family)).unwrap();
state.update(p, Event::Name(name.into())).unwrap();
state
}
#[test]
fn mounted_creation_keeps_actions_visible_and_renders_only_six_owner_candidates() {
use iced::advanced::{Layout, layout, widget};
use iced::{Rectangle, Size, Vector};
struct Geometry {
candidates: Vec<widget::Id>,
rendered: Vec<widget::Id>,
actions: Option<Rectangle>,
fields: Option<Rectangle>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("behavior-creation-actions")) {
self.actions = Some(bounds);
}
if let Some(id) = id.filter(|id| self.candidates.contains(id)) {
self.rendered.push(id.clone());
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
_: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("behavior-creation-fields")) {
self.fields = Some(bounds);
}
}
}
let mut p = Project::empty();
for index in 0..105 {
p.add_element(
Kind::Entity,
Layer::Oa,
&format!(
"{index:03} Long engineering participant name {}",
"雨 extended ".repeat(20)
),
None,
)
.unwrap();
}
let candidates: Vec<_> = p
.elements
.keys()
.map(|id| widget::Id::from(format!("behavior-creation-candidate-{id}")))
.collect();
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
let state = start(&p, Layer::Oa, family, "Very long exact behavior name 雨");
for size in [
Size::new(500., 570.),
Size::new(1050., 570.),
Size::new(500., 320.),
] {
let mut view: UiElement<'_, Event, iced::Theme, ()> = state.view_with(&p).unwrap();
let mut tree = widget::Tree::new(view.as_widget());
let node =
view.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(Size::ZERO, size));
let mut geometry = Geometry {
candidates: candidates.clone(),
rendered: Vec::new(),
actions: None,
fields: None,
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &(), &mut geometry);
let actions = geometry.actions.unwrap();
let fields = geometry.fields.unwrap();
assert!(actions.x >= 0. && actions.y >= 0.);
assert!(
actions.x + actions.width <= size.width
&& actions.y + actions.height <= size.height
);
assert!(fields.height >= 100. && fields.y + fields.height <= actions.y);
assert_eq!(
geometry.rendered.len(),
if family == Family::Machine { 6 } else { 0 }
);
}
}
}
fn roundtrip(p: Project, state: &mut State) -> Project {
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert_eq!(
command,
state.prepare_pending(&p).unwrap(),
"preparation must retain reserved IDs"
);
let api: Command = serde_json::from_value(serde_json::to_value(&command).unwrap()).unwrap();
assert_eq!(api, command);
let expected = execute_batch(&p, &[api]).unwrap();
let target = state.created().unwrap();
let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
let before_source = document.bundle().clone();
document.execute(command).unwrap();
assert_eq!(document.project(), &expected);
assert!(
state.has_pending(),
"only successful acknowledgement clears raw fields"
);
state.acknowledge_commit();
assert!(!state.has_pending());
assert!(document.project().elements.contains_key(&target.behavior));
let after_source = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.project(), &p);
assert_eq!(document.bundle(), &before_source);
assert!(!document.can_undo(), "one creation must be one Undo entry");
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &after_source);
let reopened = Document::from_bundle(after_source, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
expected
}
#[test]
fn oa_process_and_sa_chain_keep_exact_names_root_ownership_and_one_history_entry() {
for layer in [Layer::Oa, Layer::Sa] {
let p = Project::empty();
let name = " Supply \"water\" — режим ";
let mut state = start(&p, layer, Family::Sequence, name);
let target = state.created().unwrap();
let result = roundtrip(p, &mut state);
let element = &result.elements[&target.behavior];
assert_eq!(element.name, name);
assert_eq!(element.layer(), layer);
assert_eq!(
element.kind(),
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
}
);
assert_eq!(element.parent, None);
assert!(element.steps().unwrap().is_empty());
assert!(result.diagrams.is_empty());
assert!(result.relations.is_empty());
}
}
#[test]
fn all_four_viewpoints_are_explicit_and_survive_source_history_reopen() {
for layer in [Layer::Oa, Layer::Sa] {
for viewpoint in viewpoints(layer) {
let mut p = Project::empty();
let context = p
.add_element(
if layer == Layer::Oa {
Kind::Capability
} else {
Kind::SystemCapability
},
layer,
"Water supply",
None,
)
.unwrap();
let mut state = start(&p, layer, Family::Scenario, "Nominal supply");
assert!(
state
.prepare_pending(&p)
.unwrap_err()
.contains("explicitly")
);
assert!(
state
.update(&p, Event::Viewpoint(ScenarioViewpoint::MixedLegacy))
.is_err()
);
let wrong = viewpoints(if layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
})[0];
assert!(state.update(&p, Event::Viewpoint(wrong)).is_err());
state.update(&p, Event::Viewpoint(viewpoint)).unwrap();
state.update(&p, Event::Context(Some(context))).unwrap();
let id = state.created().unwrap().behavior;
let result = roundtrip(p, &mut state);
assert_eq!(
result.elements[&id].parent, None,
"context is not containment"
);
assert!(
matches!(result.elements[&id].shared(), Some(SharedBody::Scenario { viewpoint: actual, context: Some(actual_context), steps, .. }) if *actual == viewpoint && *actual_context == context && steps.is_empty())
);
}
}
}
#[test]
fn machine_requires_valid_owner_and_first_view_adds_no_region_or_state() {
for layer in [Layer::Oa, Layer::Sa] {
for first_view in [false, true] {
let mut p = Project::empty();
let owner = p
.add_element(
if layer == Layer::Oa {
Kind::Entity
} else {
Kind::System
},
layer,
"Pump",
None,
)
.unwrap();
let wrong_kind = p
.add_element(
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
"Pump water",
None,
)
.unwrap();
let opposite = if layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
};
let wrong_layer = p
.add_element(
if opposite == Layer::Oa {
Kind::Entity
} else {
Kind::System
},
opposite,
"Other layer pump",
None,
)
.unwrap();
let mut state = start(&p, layer, Family::Machine, "Pump modes");
assert!(
state
.prepare_pending(&p)
.unwrap_err()
.contains("participant")
);
assert!(state.update(&p, Event::Owner(wrong_kind)).is_err());
assert!(state.update(&p, Event::Owner(wrong_layer)).is_err());
state.update(&p, Event::Owner(owner)).unwrap();
state.update(&p, Event::FirstView(first_view)).unwrap();
let target = state.created().unwrap();
let expected_count = p.elements.len() + 1;
let result = roundtrip(p, &mut state);
assert_eq!(result.elements.len(), expected_count);
assert_eq!(result.elements[&target.behavior].parent, Some(owner));
assert_eq!(result.elements[&target.behavior].kind(), Kind::StateMachine);
assert_eq!(result.diagrams.len(), usize::from(first_view));
if let Some(view) = target.diagram {
let diagram = &result.diagrams[&view];
assert_eq!(
diagram.projection,
ProjectionKind::StateMachine {
machine: target.behavior
}
);
assert_eq!(diagram.nodes.len(), 1);
assert_eq!(
diagram.nodes.values().next().unwrap().element,
target.behavior
);
}
}
}
}
#[test]
fn cancel_and_illegal_family_layer_switches_never_create_model_objects() {
let p = Project::empty();
let mut state = start(&p, Layer::Oa, Family::Scenario, "Raw name");
state
.update(
&p,
Event::Viewpoint(ScenarioViewpoint::ParticipantInteraction),
)
.unwrap();
let snapshot = state.recovery_snapshot().unwrap();
assert!(state.update(&p, Event::Open(Layer::Sa)).is_err());
assert!(state.update(&p, Event::Choose(Family::Sequence)).is_err());
assert_eq!(state.recovery_snapshot().unwrap(), snapshot);
assert!(state.update(&p, Event::Cancel).unwrap().is_none());
assert!(!state.has_pending());
assert!(state.created().is_none());
assert!(state.prepare_pending(&p).is_err());
assert!(p.elements.is_empty());
assert!(p.diagrams.is_empty());
}
#[test]
fn recovery_preserves_exact_raw_inputs_reserved_ids_and_revalidates_stale_owner() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Plant", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Machine, " \nRaw rejected name\0 ");
state.update(&p, Event::Owner(owner)).unwrap();
state.update(&p, Event::FirstView(true)).unwrap();
state
.update(&p, Event::Search(Picker::Owner, " Plant ".into()))
.unwrap();
let raw = state.recovery_snapshot().unwrap().unwrap();
let mut restored = State::from_recovery(raw.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw.clone()));
assert_eq!(restored.created(), state.created());
assert_eq!(restored.pending_layer(), Some(Layer::Oa));
assert!(restored.prepare_pending(&p).is_err());
restored
.update(&p, Event::Name("Plant modes".into()))
.unwrap();
let command = restored.prepare_pending(&p).unwrap();
let again = State::from_recovery(restored.recovery_snapshot().unwrap().unwrap()).unwrap();
assert_eq!(again.prepare_pending(&p).unwrap(), command);
p.elements.remove(&owner);
let before = restored.recovery_snapshot().unwrap();
assert!(
restored
.prepare_pending(&p)
.unwrap_err()
.contains("participant")
);
assert_eq!(restored.recovery_snapshot().unwrap(), before);
let different = Project::empty();
assert!(
restored
.prepare_pending(&different)
.unwrap_err()
.contains("another document")
);
}
#[test]
fn rejected_source_transaction_keeps_draft_and_every_accepted_source_byte() {
let p = Project::empty();
let initial = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
let bytes: usize = initial.bundle().files.values().map(String::len).sum();
let limits = ProjectLimits {
model: language::ParseLimits {
max_source_bytes: bytes + 32,
..language::ParseLimits::UNLIMITED
},
..ProjectLimits::default()
};
let mut doc = Document::from_bundle(initial.bundle().clone(), limits).unwrap();
let mut state = start(
&p,
Layer::Oa,
Family::Sequence,
"A long exact name retained after the source budget refuses this new process",
);
let raw = state.recovery_snapshot().unwrap();
let command = state.prepare_pending(&p).unwrap();
assert!(doc.execute(command).is_err());
assert_eq!(doc.project(), &p);
assert_eq!(doc.bundle(), initial.bundle());
assert!(!doc.can_undo());
assert_eq!(state.recovery_snapshot().unwrap(), raw);
state.update(&p, Event::Cancel).unwrap();
}
#[test]
fn owner_search_is_name_ordered_filtered_and_page_sized_not_a_catalog() {
let mut p = Project::empty();
for index in (0..105).rev() {
p.add_element(Kind::Entity, Layer::Oa, &format!("Pump {index:03}"), None)
.unwrap();
}
p.add_element(Kind::System, Layer::Sa, "Pump 999", None)
.unwrap();
p.add_element(Kind::Activity, Layer::Oa, "Pump work", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump modes");
let all = choices(&p, Layer::Oa, Picker::Owner, "");
assert_eq!(all.len(), 105);
assert_eq!(all[0].name, "Pump 000");
state
.update(&p, Event::Page(Picker::Owner, usize::MAX))
.unwrap();
assert!(
state.view(&p).is_some(),
"stale pages are clamped before slicing"
);
state
.update(&p, Event::Search(Picker::Owner, "Pump 104".into()))
.unwrap();
assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0);
let found = choices(&p, Layer::Oa, Picker::Owner, "Pump 104");
assert_eq!(found.len(), 1);
state.update(&p, Event::Owner(found[0].id)).unwrap();
assert_eq!(state.draft.as_ref().unwrap().owner, Some(found[0].id));
}
#[test]
fn repeated_owner_names_remain_distinguishable_by_searchable_parent_context_and_alias() {
let mut p = Project::empty();
let north = p
.add_element(Kind::Entity, Layer::Oa, "North station", None)
.unwrap();
let south = p
.add_element(Kind::Entity, Layer::Oa, "South station", None)
.unwrap();
let first = p
.add_element(Kind::Entity, Layer::Oa, "Pump", Some(north))
.unwrap();
let second = p
.add_element(Kind::Entity, Layer::Oa, "Pump", Some(south))
.unwrap();
let label = reference_label(&p, second);
assert_ne!(reference_label(&p, first), label);
assert!(label.contains("South station") && label.contains("Pump"));
assert!(label.contains(&p.elements[&second].alias));
let found = choices(&p, Layer::Oa, Picker::Owner, "south station");
assert!(found.iter().any(|e| e.id == second));
assert!(!found.iter().any(|e| e.id == first));
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump states");
state.update(&p, Event::Owner(second)).unwrap();
let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap();
assert_eq!(
result.elements[&state.created().unwrap().behavior].parent,
Some(second)
);
}
#[test]
fn corrupted_recovery_and_reserved_identity_collision_refuse_without_repairing_raw_values() {
let p = Project::empty();
let state = start(&p, Layer::Sa, Family::Sequence, "Supply");
let raw = state.recovery_snapshot().unwrap().unwrap();
for (field, value) in [
("version", serde_json::json!(99)),
("unexpected", serde_json::json!(true)),
] {
let mut bad = raw.clone();
bad[field] = value;
assert!(State::from_recovery(bad).is_err());
}
let mut bad = raw;
bad["element"] = serde_json::json!(p.id);
let restored = State::from_recovery(bad.clone()).unwrap();
assert!(
restored
.prepare_pending(&p)
.unwrap_err()
.contains("identity")
);
assert_eq!(restored.recovery_snapshot().unwrap(), Some(bad));
}
#[test]
fn post_commit_selection_is_correlated_with_exact_created_behavior_and_view() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Pump", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump modes");
state.update(&p, Event::Owner(owner)).unwrap();
state.update(&p, Event::FirstView(true)).unwrap();
let command = state.prepare_pending(&p).unwrap();
assert_eq!(state.created_by(&p, &command), state.created());
assert_eq!(
state.created_by(&p, &Command::Rename(owner, "Unrelated change".into())),
None
);
for edit in 0..4 {
let mut changed = command.clone();
let Command::Batch(ref mut commands) = changed else {
panic!("creation batch");
};
if edit < 3 {
let Command::CreateElement(element) = &mut commands[0] else {
panic!("new behavior");
};
match edit {
0 => element.id = Id::new_v4(),
1 => element.name = "A different authored draft".into(),
_ => element.parent = None,
}
} else {
let Command::CreateDiagram(diagram) = &mut commands[1] else {
panic!("new view");
};
diagram.projection = ProjectionKind::StateMachine { machine: owner };
}
assert_eq!(state.created_by(&p, &changed), None);
}
let Command::Batch(commands) = command else {
panic!("creation batch");
};
assert_eq!(
state.created_by(&p, &commands[0]),
None,
"an opt-in view must be part of the actual committed batch"
);
}
#[test]
fn optional_context_collapses_without_losing_authored_reference_or_raw_search() {
let mut p = Project::empty();
let context = p
.add_element(Kind::Capability, Layer::Oa, "Water supply", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Scenario, "Supply nominal");
assert!(!state.draft.as_ref().unwrap().context_open);
state
.update(&p, Event::Viewpoint(ScenarioViewpoint::OperationalActivity))
.unwrap();
state.update(&p, Event::ContextOptions(true)).unwrap();
state
.update(&p, Event::Search(Picker::Context, " Water ".into()))
.unwrap();
state.update(&p, Event::Context(Some(context))).unwrap();
let command = state.prepare_pending(&p).unwrap();
state.update(&p, Event::ContextOptions(false)).unwrap();
let restored = State::from_recovery(state.recovery_snapshot().unwrap().unwrap()).unwrap();
assert!(!restored.draft.as_ref().unwrap().context_open);
assert_eq!(
restored.draft.as_ref().unwrap().browsers[1].query,
" Water "
);
assert_eq!(restored.prepare_pending(&p).unwrap(), command);
assert_eq!(restored.created_by(&p, &command), restored.created());
}
#[test]
fn creation_selection_rejects_changed_scenario_body_and_post_creation_operations() {
let mut p = Project::empty();
let capability = p
.add_element(Kind::SystemCapability, Layer::Sa, "Supply", None)
.unwrap();
let mut state = start(&p, Layer::Sa, Family::Scenario, "Nominal functions");
state
.update(&p, Event::Viewpoint(ScenarioViewpoint::Functional))
.unwrap();
let command = state.prepare_pending(&p).unwrap();
assert_eq!(state.created_by(&p, &command), state.created());
for edit in 0..4 {
let mut altered = command.clone();
let Command::Batch(commands) = &mut altered else {
panic!("creation batch");
};
match edit {
0 => {
let Command::CreateElement(element) = &mut commands[0] else {
panic!("scenario");
};
let Some(SharedBody::Scenario { viewpoint, .. }) = element.shared_mut() else {
panic!("scenario body");
};
*viewpoint = ScenarioViewpoint::BoundaryInterface;
}
1 => {
let Command::CreateElement(element) = &mut commands[0] else {
panic!("scenario");
};
let Some(SharedBody::Scenario { context, .. }) = element.shared_mut() else {
panic!("scenario body");
};
*context = Some(capability);
}
2 => commands.push(Command::Rename(
state.created().unwrap().behavior,
"Changed afterward".into(),
)),
_ => commands.push(Command::Delete(state.created().unwrap().behavior)),
}
assert!(
execute_batch(&p, std::slice::from_ref(&altered)).is_ok(),
"the alternate command is valid, but is not this authored creation"
);
assert_eq!(state.created_by(&p, &altered), None);
}
}
+61 -492
View File
@@ -4,6 +4,8 @@ use super::topology::{occurrence, unique_batch};
use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
mod workspace;
pub use workspace::{Shelf, view};
#[cfg(test)]
#[path = "scenario_operation_tests.rs"]
@@ -94,6 +96,10 @@ impl std::fmt::Display for CanvasTool {
pub enum CanvasEvent {
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
Viewport(Context, Size),
Zoom(Context, Option<f32>),
Focus(Context),
Shelf(Context, Shelf),
OpenUse(Context, Id),
Back(Context),
Choose(Context, CanvasTool),
@@ -120,6 +126,7 @@ pub enum CanvasEvent {
#[derive(Debug, Clone, Copy)]
pub enum ScrollArea {
Canvas,
// Retained navigation channel, now the independent Selection/outline shelf.
Page,
}
impl CanvasEvent {
@@ -127,6 +134,10 @@ impl CanvasEvent {
match self {
Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
| Self::Zoom(context, _)
| Self::Focus(context)
| Self::Shelf(context, _)
| Self::OpenUse(context, _)
| Self::Back(context)
| Self::Choose(context, _)
@@ -191,6 +202,7 @@ pub struct State {
scroll_offsets: [iced::Vector; 2],
return_to: Vec<Caller>,
restore: Option<ViewportRestore>,
presentation: workspace::Presentation,
}
const CALLER_LIMIT: usize = 32;
pub(crate) struct Caller {
@@ -200,6 +212,7 @@ pub(crate) struct Caller {
target: Id,
tool: CanvasTool,
offsets: [iced::Vector; 2],
presentation: workspace::Presentation,
}
struct ViewportRestore {
document: Id,
@@ -259,6 +272,32 @@ struct Draft {
choice_page: usize,
}
impl State {
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.presentation.identity != Some((document, owner)) {
self.presentation = workspace::Presentation {
identity: Some((document, owner)),
focused: self.presentation.focused,
..Default::default()
};
self.scroll_offsets = [iced::Vector::ZERO; 2];
self.restore = None;
}
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
self.presentation.viewport(size)
}
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
self.presentation.zoom(scale)
}
pub(crate) fn focused(&self) -> bool {
self.presentation.focused
}
pub(crate) fn toggle_focus(&mut self) {
self.presentation.focused = !self.presentation.focused;
}
pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) {
self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf);
}
pub(crate) fn open_use(
&mut self,
p: &Project,
@@ -279,6 +318,7 @@ impl State {
target,
tool: self.canvas_tool(context.scenario),
offsets: self.scroll_offsets,
presentation: self.presentation.clone(),
});
self.canvas_tool = Some((target, CanvasTool::Select));
self.scroll_offsets = [iced::Vector::ZERO; 2];
@@ -309,6 +349,8 @@ impl State {
}
self.canvas_tool = Some((frame.owner, frame.tool));
self.scroll_offsets = frame.offsets;
self.presentation = frame.presentation;
self.presentation.identity = Some((context.document, context.scenario));
self.restore = Some(ViewportRestore {
document: context.document,
owner: frame.owner,
@@ -378,6 +420,9 @@ impl State {
}
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
return false;
}
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
@@ -387,7 +432,8 @@ impl State {
changed
}
fn scroll_offset(&self) -> iced::Vector {
self.scroll_offsets[0] + self.scroll_offsets[1]
// The former page scroll now belongs to a sibling outline shelf.
self.scroll_offsets[0]
}
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
let key = CallChoiceContext {
@@ -1634,493 +1680,6 @@ fn bounded_picker<'a>(
}
out.into()
}
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
let p = app.project();
out = out.push(label(match viewpoint(p, owner) {
ScenarioViewpoint::OperationalActivity => {
"Operational activity scenario · lifelines represent activities"
}
ScenarioViewpoint::Functional => {
"Functional scenario · lifelines represent system functions"
}
ScenarioViewpoint::ParticipantInteraction => {
"Participant scenario · lifelines represent entities, actors or roles"
}
ScenarioViewpoint::BoundaryInterface => {
"Interface scenario · lifelines represent the system and external actors"
}
ScenarioViewpoint::MixedLegacy => {
"Mixed scenario · lifelines may represent work or participants"
}
}));
let pending = app.behavior.has_pending();
let context = app.scenario_context().expect("visible scenario context");
let tool = app.behavior.scenario.canvas_tool(owner);
// Keep the canvas at a stable widget-tree position when a form replaces
// the palette, so Iced retains the user's current sequence viewport.
let mut controls = column![].spacing(6);
if !pending {
controls = controls.push(
row![
button("Select / reorder")
.on_press(Message::ScenarioCanvas(CanvasEvent::Choose(
context,
CanvasTool::Select
)))
.style(if tool == CanvasTool::Select {
style::selected
} else {
style::nav
}),
pick_list(
vec![
CanvasTool::Select,
CanvasTool::Message(MessageStyle::CallReply),
CanvasTool::Message(MessageStyle::Asynchronous),
CanvasTool::Message(MessageStyle::Call),
CanvasTool::Message(MessageStyle::Reply),
CanvasTool::Message(MessageStyle::Create),
CanvasTool::Message(MessageStyle::Delete),
CanvasTool::Message(MessageStyle::Timer),
CanvasTool::Add(Tool::Action),
CanvasTool::Add(Tool::Observation),
CanvasTool::Add(Tool::Reuse),
CanvasTool::Add(Tool::Fragment),
],
Some(tool),
move |tool| Message::ScenarioCanvas(CanvasEvent::Choose(context, tool))
)
.text_size(12)
.padding(8)
.width(240),
action("+ Lifeline", event(Event::Start(owner, Tool::Lifeline))),
action("Properties", Message::EditProperties(owner)),
]
.spacing(6)
.wrap(),
);
controls = controls.push(label(match tool {
CanvasTool::Message(_) => "Draw message · choose a sequence position on a lifeline, then click or drag to another lifeline · Escape cancels",
CanvasTool::Add(Tool::Action | Tool::Observation) => "Click a highlighted sequence position · click on a lifeline to preselect it · Escape cancels",
CanvasTool::Add(Tool::Reuse) => "Click a highlighted sequence position to reuse a scenario and bind its lifelines · Escape cancels",
CanvasTool::Add(Tool::Fragment) => "Click a highlighted sequence position to insert an empty fragment with operands · Escape cancels",
_ => "Select a lifeline or occurrence · drag its ↕ handle to reorder within its sequence",
}));
} else {
controls = controls.push(label(
"Pending form · the canvas remains visible · apply or cancel below",
));
}
let clean = !pending && !app.draft_dirty() && !app.source_dirty();
let mut navigation = row![].spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
navigation = navigation.push(button("Open definition").on_press_maybe(clean.then_some(
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, app.selected)),
)));
}
if app.behavior.scenario.can_back(p, context) {
navigation =
navigation.push(button("Back to caller").on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
));
}
controls = controls.push(navigation.wrap());
out = out.push(controls);
let mut scene = Scene::sequence(p, owner, app.selected);
scene.interaction = Some((
context,
tool,
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
));
scene.scroll_offset = app.behavior.scenario.scroll_offset();
scene.pending_placement = app.behavior.scenario.pending_placement(owner);
let size = scene.size;
out = out.push(
container(super::scenario_scroll::observe(
context,
ScrollArea::Canvas,
app.behavior
.scenario
.scroll_restore(context, ScrollArea::Canvas),
scrollable(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height),
)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(if pending {
(app.window_size.height - 660.).clamp(110., 190.)
} else {
(app.window_size.height - 410.).clamp(180., 340.)
}),
))
.padding(8)
.style(style::card),
);
if let Some(d) = app
.behavior
.scenario
.draft
.as_ref()
.filter(|d| d.owner == owner)
{
let mut form = column![
text(d.tool.label()).size(16),
text_input("Name (defaults to selected source)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12)
]
.spacing(9);
if d.tool != Tool::Lifeline {
form = form.push(label(format!(
"Insert: {}",
slot_label(
p,
Slot {
parent: d.parent,
before: d.before
}
)
)));
if d.before.is_some() {
form = form.push(
button("Append to this sequence instead").on_press(event(Event::AppendInstead)),
);
}
form = form.push(picker(
parents(p, owner),
Some(d.parent),
"Destination sequence / operand…",
Event::Parent,
));
}
if matches!(
d.tool,
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
) {
form = form.push(picker(
sources(p, d),
d.target,
"Choose a named source…",
Event::Target,
));
}
if d.tool == Tool::Message {
form = form.push(
pick_list(
vec![
MessageStyle::Asynchronous,
MessageStyle::Call,
MessageStyle::CallReply,
MessageStyle::Reply,
MessageStyle::Create,
MessageStyle::Delete,
MessageStyle::Timer,
],
Some(d.style),
|style| event(Event::Style(style)),
)
.width(Fill)
.padding(9)
.text_size(12),
);
if d.style == MessageStyle::Reply {
form = form.push(bounded_picker(
app.behavior.scenario.call_choices(p, context, d),
d,
d.call,
"Choose the earlier call…",
Event::Call,
));
if d.call.is_some() {
form = form.push(label(
"Reply reuses the call exchange in reverse and retains its correlation.",
));
}
} else if d.style != MessageStyle::Timer {
form = form.push(bounded_picker(
exchanges(p, d),
d,
d.exchange,
"Choose the exchange…",
Event::Exchange,
));
form = form.push(label(
"Apply checks that the message is valid at this insertion position.",
));
}
form = form.push(picker(
d.line_choices(p, true),
d.sender,
"Sending lifeline…",
Event::Sender,
));
if d.style != MessageStyle::Timer {
form = form.push(picker(
d.line_choices(p, false),
d.receiver,
"Receiving lifeline…",
Event::Receiver,
));
}
if d.style == MessageStyle::Timer {
form = form.push(
text_input("Duration / deadline, e.g. 30 s", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if p.layer_of(owner) == Some(Layer::Sa)
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
&& matches!(
d.style,
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
)
{
form = form.push(
iced::widget::checkbox(d.operation.is_some())
.label("Invoke a specific interface operation")
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
);
if let Some(operation) = &d.operation {
form = form
.push(bounded_picker(
crate::data_values::operation_choices(p, &d.proposed_message(p)),
d,
operation.allocation,
"Choose the exact interface operation allocation…",
Event::Operation,
))
.push(
button("Keep operation unresolved")
.on_press(event(Event::ClearOperation)),
);
if let Some(id) = operation.allocation {
form = form.push(label(crate::data_values::operation_identity(p, id)));
}
}
}
if let Some(description) =
crate::data_values::operation_label(p, &d.proposed_message(p))
{
form = form.push(label(description));
}
match d.signature(p) {
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
Err(problem) => {
form = form.push(label(problem));
for (key, expression) in d.authored_values() {
form = form
.push(
text(format!(
"Retained value · {} / {}",
key.path
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" → "),
p.named(key.field)
))
.size(11),
)
.push(
text_input("Value expression", expression)
.on_input({
let key = key.clone();
move |raw| event(Event::Value(key.clone(), raw))
})
.padding(8)
.size(12),
);
}
}
Ok(None) => {}
}
if d.style == MessageStyle::CallReply {
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
}
}
if matches!(d.tool, Tool::Action | Tool::Observation) {
form = form.push(picker(
lines(p, owner),
d.sender,
"Observed lifeline…",
Event::Sender,
));
}
if d.tool == Tool::Reuse
&& let Some(target) = d.target
{
for line in lines(p, target) {
let referenced = line.id;
let compatible: Vec<_> = lines(p, owner)
.into_iter()
.filter(|c| {
compatible_binding(p, referenced, c.id)
&& !d
.bindings
.iter()
.any(|(r, l)| *r != referenced && *l == c.id)
})
.collect();
if compatible.is_empty() {
form = form.push(label(format!(
"Add a compatible local lifeline before binding '{}'.",
p.named(referenced)
)));
}
form = form
.push(label(format!("Bind {}", p.named(referenced))))
.push(picker(
compatible,
d.bindings.get(&referenced).copied(),
"Compatible local lifeline…",
move |c| Event::Binding(referenced, c),
));
}
form = form.push(label(
"Bindings are explicit, one-to-one and refer to existing lifelines.",
));
}
if d.tool == Tool::Fragment {
form = form.push(
pick_list(
vec![
Operator(FragmentOperator::Alternative),
Operator(FragmentOperator::Parallel),
Operator(FragmentOperator::Loop),
Operator(FragmentOperator::Optional),
],
Some(Operator(d.operator)),
|o| event(Event::Operator(o)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
if d.operator != FragmentOperator::Parallel {
form = form.push(
text_input("Condition / loop guard", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if d.operator == FragmentOperator::Alternative {
form = form.push(
text_input("Second alternative guard", &d.second_guard)
.on_input(|s| event(Event::SecondGuard(s)))
.padding(9)
.size(12),
);
}
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
}
let readiness = d.command(p);
if let Err(error) = &readiness {
form = form.push(label(error.clone()));
}
let footer = row![
button("Create")
.padding(9)
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7);
out = out.push(
container(
column![
scrollable(form).height((app.window_size.height - 650.).clamp(110., 240.)),
footer
]
.spacing(9),
)
.padding(14)
.style(style::card),
);
}
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
out = out.push(container(label("Add lifelines for the participating work or actors, then create messages between them.")).padding(16).width(Fill).style(style::card));
} else if !pending {
out = sequence_rows(p, context, owner, out);
}
super::scenario_scroll::observe(
context,
ScrollArea::Page,
app.behavior
.scenario
.scroll_restore(context, ScrollArea::Page),
scrollable(out.padding(24)),
)
}
fn sequence_rows<'a>(
p: &'a Project,
context: Context,
owner: Id,
mut out: Column<'a, Message>,
) -> Column<'a, Message> {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get(&owner).and_then(|e| e.shared())
{
for branch in branches {
out = out.push(action(
&format!("Operand: {}", p.named(*branch)),
Message::Select(*branch),
));
out = sequence_rows(p, context, *branch, out);
}
}
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
for (i, id) in steps.iter().enumerate() {
out =
out.push(
row![
action(
&format!("{}. {}", i + 1, p.named(*id)),
Message::Select(*id)
)
.width(Fill),
button("↑")
.padding(8)
.on_press_maybe((i > 0).then_some(Message::Command(
Command::MoveStep(owner, i, i.saturating_sub(1))
))),
button("↓").padding(8).on_press_maybe(
(i + 1 < steps.len()).then_some(Message::Command(Command::MoveStep(
owner,
i,
i + 1
)))
)
]
.spacing(6),
);
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::ScenarioUse {
scenario: Some(target),
..
},
..
}) = p.elements[id].shared()
{
out = out.push(action(
&format!("Open {} →", p.named(*target)),
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, *id)),
));
}
out = sequence_rows(p, context, *id, out);
}
}
out
}
#[cfg(test)]
pub(crate) mod direct_tests {
@@ -2180,7 +1739,7 @@ pub(crate) mod direct_tests {
epoch: 0,
}
}
fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
pub(super) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
let mut f = fixture();
let p = &mut f.project;
let machine = p
@@ -2334,7 +1893,12 @@ pub(crate) mod direct_tests {
);
assert_eq!(
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 250.)
iced::Vector::new(25., 170.)
);
assert_eq!(
app.behavior.scenario.scroll_offsets[1],
iced::Vector::new(0., 80.),
"The caller's independent outline scroll is restored separately"
);
assert!(
!app.behavior
@@ -2471,6 +2035,11 @@ pub(crate) mod direct_tests {
assert!(app.document.can_undo(), "{}", app.status);
navigation_event(&mut app, CanvasEvent::Back);
assert_eq!(app.selected, if edit == 0 { first } else { b });
assert_eq!(
app.scenario_context().unwrap().scenario,
if edit == 0 { a } else { b },
"Back must restore the caller workspace even if a rename already selected its UUID"
);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.document.revision(), revision);
@@ -2909,7 +2478,7 @@ pub(crate) mod direct_tests {
assert!(app.busy);
assert_eq!(
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 140.)
iced::Vector::new(25., 100.)
);
assert_ne!(
app.scenario_context(),
@@ -2925,7 +2494,7 @@ pub(crate) mod direct_tests {
)));
assert_eq!(
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 140.)
iced::Vector::new(25., 100.)
);
let root = std::path::PathBuf::from("scenario-scroll-save-fixture");
let receipt = crate::app::FileSnapshot {
@@ -0,0 +1,749 @@
//! Scenario presentation. Layout/camera controls never construct model commands.
use super::super::scenario_scroll;
use super::*;
use iced::widget::{Space, column, sensor};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shelf {
Tools,
Selection,
}
#[derive(Clone, Default)]
pub(super) struct Presentation {
pub(super) identity: Option<(Id, Id)>,
pub(super) viewport: Option<Size>,
pub(super) focused: bool,
pub(super) shelf: Option<Shelf>,
pub(super) zoom: Option<f32>,
pub(super) fit: bool,
}
const MIN_SCALE: f32 = 0.05;
impl Presentation {
pub(super) fn viewport(&mut self, size: Size) -> bool {
if !size.width.is_finite()
|| !size.height.is_finite()
|| size.width <= 0.
|| size.height <= 0.
|| self.viewport == Some(size)
{
return false;
}
self.viewport = Some(size);
true
}
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
return false;
}
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
let changed = self.zoom != next || self.fit != scale.is_none();
self.zoom = next;
self.fit = scale.is_none();
changed
}
fn fit_scale(&self, scene: Size) -> Option<f32> {
let viewport = self.viewport?;
// This is the measured inner Scrollable, never a window-height estimate.
// Leave room for the overlay scrollbars on both axes.
Some(
((viewport.width - 16.).max(1.) / scene.width.max(1.))
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
)
}
fn scale(&self, scene: Size) -> f32 {
if self.fit {
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
} else {
self.zoom.unwrap_or(1.)
}
}
fn fit_limited(&self, scene: Size) -> bool {
self.fit && self.fit_scale(scene).is_some_and(|scale| scale < MIN_SCALE)
}
}
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
let p = app.project();
let state = &app.behavior.scenario;
let presentation = &state.presentation;
let pending = state
.draft
.as_ref()
.is_some_and(|draft| draft.owner == owner);
let context = app.scenario_context().expect("visible scenario context");
let tool = state.canvas_tool(owner);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let mut scene = Scene::sequence(p, owner, app.selected);
let scale = presentation.scale(scene.size);
let limited = presentation.fit_limited(scene.size);
scene.scale = scale;
scene.interaction = Some((context, tool, clean));
// The outline is a sibling shelf, not an ancestor of this canvas.
scene.scroll_offset = state.scroll_offset();
scene.pending_placement = state.pending_placement(owner);
let size = Size::new(scene.size.width * scale, scene.size.height * scale);
let canvas = canvas_pane(
context,
state.scroll_restore(context, ScrollArea::Canvas),
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height)
.into(),
);
let mut navigation = row![
out.width(Fill),
button(
text(if presentation.focused {
"Restore panels"
} else {
"Focus canvas"
})
.size(12)
)
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Focus(context)))
]
.spacing(6)
.align_y(iced::Center);
if state.can_back(p, context) {
navigation = navigation.push(
button(text("Back to caller").size(12))
.padding(6)
.on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
),
);
}
let mut header = column![
navigation,
row![
button(text("Tools").size(12))
.padding(6)
.style(if presentation.shelf == Some(Shelf::Tools) {
button::primary
} else {
style::nav
})
.on_press(Message::ScenarioCanvas(CanvasEvent::Shelf(
context,
Shelf::Tools
))),
button(text("Selection").size(12))
.padding(6)
.style(if presentation.shelf == Some(Shelf::Selection) {
button::primary
} else {
style::nav
})
.on_press(Message::ScenarioCanvas(CanvasEvent::Shelf(
context,
Shelf::Selection
))),
button(text("Fit overview").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(context, None))),
button(text("−").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(scale / 1.25)
))),
text(format!("{:.0}%", scale * 100.)).size(12),
button(text("+").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(scale * 1.25)
))),
button(text("100%").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(1.)
)))
]
.spacing(6)
.align_y(iced::Center)
.wrap(),
text(if pending {
"Pending form · the highlighted sequence position is retained".to_owned()
} else {
let guidance = match tool {
CanvasTool::Message(_) => {
"Click a source position, then click or drag to a target · Esc cancels"
}
CanvasTool::Add(Tool::Action | Tool::Observation) => {
"Click a highlighted position · a lifeline click preselects that lifeline"
}
CanvasTool::Add(Tool::Reuse | Tool::Fragment) => {
"Click a highlighted sequence position to open its form"
}
_ => "Select a lifeline or occurrence · drag its ↕ handle to reorder",
};
format!("{tool} · {guidance}")
})
.size(11)
]
.spacing(6);
if limited {
header = header
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
}
let details = match presentation.shelf {
Some(Shelf::Tools) => {
let choose = move |tool| Message::ScenarioCanvas(CanvasEvent::Choose(context, tool));
let tool_picker: Element<'_, Message> = if clean {
pick_list(
vec![
CanvasTool::Select,
CanvasTool::Message(MessageStyle::CallReply),
CanvasTool::Message(MessageStyle::Asynchronous),
CanvasTool::Message(MessageStyle::Call),
CanvasTool::Message(MessageStyle::Reply),
CanvasTool::Message(MessageStyle::Create),
CanvasTool::Message(MessageStyle::Delete),
CanvasTool::Message(MessageStyle::Timer),
CanvasTool::Add(Tool::Action),
CanvasTool::Add(Tool::Observation),
CanvasTool::Add(Tool::Reuse),
CanvasTool::Add(Tool::Fragment),
],
Some(tool),
choose,
)
.text_size(12)
.padding(6)
.width(Fill)
.into()
} else {
button(text(format!("{tool} · finish pending edits to change tools")).size(12))
.padding(6)
.width(Fill)
.into()
};
column![
tool_picker,
row![
button(text("+ Lifeline").size(12))
.padding(6)
.on_press_maybe(
clean.then_some(event(Event::Start(owner, Tool::Lifeline)))
),
button(text("Scenario properties").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::EditProperties(owner))),
]
.spacing(6)
.wrap(),
text(match viewpoint(p, owner) {
ScenarioViewpoint::OperationalActivity =>
"Activity scenario · lifelines represent activities",
ScenarioViewpoint::Functional =>
"Functional scenario · lifelines represent system functions",
ScenarioViewpoint::ParticipantInteraction =>
"Participant scenario · entities, actors or roles",
ScenarioViewpoint::BoundaryInterface =>
"Interface scenario · system and external actors",
ScenarioViewpoint::MixedLegacy => "Mixed scenario · work or participants",
})
.size(11),
]
.spacing(6)
}
_ => {
let mut details =
column![text(format!("Selected: {}", p.named(app.selected))).size(12)].spacing(6);
let mut navigation = row![].spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
navigation = navigation.push(
button(text("Open definition").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::ScenarioCanvas(
CanvasEvent::OpenUse(context, app.selected),
))),
);
}
navigation = navigation.push(
button(text("Properties").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected))),
);
details = details.push(navigation.wrap());
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
details.push(
text("Use Tools to add lifelines, then draw messages or insert occurrences.")
.size(12),
)
} else {
sequence_rows(p, context, owner, details, clean)
}
}
};
let details = details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
details.into(),
presentation.shelf.is_some(),
pending,
);
let mut form_panel = editor_panel(Space::new().height(0).into(), Space::new().height(0).into());
if let Some(d) = app
.behavior
.scenario
.draft
.as_ref()
.filter(|d| d.owner == owner)
{
let mut form = column![
text(d.tool.label()).size(16),
text_input("Name (defaults to selected source)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12)
]
.spacing(9);
if d.tool != Tool::Lifeline {
form = form.push(label(format!(
"Insert: {}",
slot_label(
p,
Slot {
parent: d.parent,
before: d.before
}
)
)));
if d.before.is_some() {
form = form.push(
button("Append to this sequence instead").on_press(event(Event::AppendInstead)),
);
}
form = form.push(picker(
parents(p, owner),
Some(d.parent),
"Destination sequence / operand…",
Event::Parent,
));
}
if matches!(
d.tool,
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
) {
form = form.push(picker(
sources(p, d),
d.target,
"Choose a named source…",
Event::Target,
));
}
if d.tool == Tool::Message {
form = form.push(
pick_list(
vec![
MessageStyle::Asynchronous,
MessageStyle::Call,
MessageStyle::CallReply,
MessageStyle::Reply,
MessageStyle::Create,
MessageStyle::Delete,
MessageStyle::Timer,
],
Some(d.style),
|style| event(Event::Style(style)),
)
.width(Fill)
.padding(9)
.text_size(12),
);
if d.style == MessageStyle::Reply {
form = form.push(bounded_picker(
app.behavior.scenario.call_choices(p, context, d),
d,
d.call,
"Choose the earlier call…",
Event::Call,
));
if d.call.is_some() {
form = form.push(label(
"Reply reuses the call exchange in reverse and retains its correlation.",
));
}
} else if d.style != MessageStyle::Timer {
form = form.push(bounded_picker(
exchanges(p, d),
d,
d.exchange,
"Choose the exchange…",
Event::Exchange,
));
form = form.push(label(
"Apply checks that the message is valid at this insertion position.",
));
}
form = form.push(picker(
d.line_choices(p, true),
d.sender,
"Sending lifeline…",
Event::Sender,
));
if d.style != MessageStyle::Timer {
form = form.push(picker(
d.line_choices(p, false),
d.receiver,
"Receiving lifeline…",
Event::Receiver,
));
}
if d.style == MessageStyle::Timer {
form = form.push(
text_input("Duration / deadline, e.g. 30 s", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if p.layer_of(owner) == Some(Layer::Sa)
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
&& matches!(
d.style,
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
)
{
form = form.push(
iced::widget::checkbox(d.operation.is_some())
.label("Invoke a specific interface operation")
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
);
if let Some(operation) = &d.operation {
form = form
.push(bounded_picker(
crate::data_values::operation_choices(p, &d.proposed_message(p)),
d,
operation.allocation,
"Choose the exact interface operation allocation…",
Event::Operation,
))
.push(
button("Keep operation unresolved")
.on_press(event(Event::ClearOperation)),
);
if let Some(id) = operation.allocation {
form = form.push(label(crate::data_values::operation_identity(p, id)));
}
}
}
if let Some(description) =
crate::data_values::operation_label(p, &d.proposed_message(p))
{
form = form.push(label(description));
}
match d.signature(p) {
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
Err(problem) => {
form = form.push(label(problem));
for (key, expression) in d.authored_values() {
form = form
.push(
text(format!(
"Retained value · {} / {}",
key.path
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" → "),
p.named(key.field)
))
.size(11),
)
.push(
text_input("Value expression", expression)
.on_input({
let key = key.clone();
move |raw| event(Event::Value(key.clone(), raw))
})
.padding(8)
.size(12),
);
}
}
Ok(None) => {}
}
if d.style == MessageStyle::CallReply {
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
}
}
if matches!(d.tool, Tool::Action | Tool::Observation) {
form = form.push(picker(
lines(p, owner),
d.sender,
"Observed lifeline…",
Event::Sender,
));
}
if d.tool == Tool::Reuse
&& let Some(target) = d.target
{
for line in lines(p, target) {
let referenced = line.id;
let compatible: Vec<_> = lines(p, owner)
.into_iter()
.filter(|c| {
compatible_binding(p, referenced, c.id)
&& !d
.bindings
.iter()
.any(|(r, l)| *r != referenced && *l == c.id)
})
.collect();
if compatible.is_empty() {
form = form.push(label(format!(
"Add a compatible local lifeline before binding '{}'.",
p.named(referenced)
)));
}
form = form
.push(label(format!("Bind {}", p.named(referenced))))
.push(picker(
compatible,
d.bindings.get(&referenced).copied(),
"Compatible local lifeline…",
move |c| Event::Binding(referenced, c),
));
}
form = form.push(label(
"Bindings are explicit, one-to-one and refer to existing lifelines.",
));
}
if d.tool == Tool::Fragment {
form = form.push(
pick_list(
vec![
Operator(FragmentOperator::Alternative),
Operator(FragmentOperator::Parallel),
Operator(FragmentOperator::Loop),
Operator(FragmentOperator::Optional),
],
Some(Operator(d.operator)),
|o| event(Event::Operator(o)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
if d.operator != FragmentOperator::Parallel {
form = form.push(
text_input("Condition / loop guard", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if d.operator == FragmentOperator::Alternative {
form = form.push(
text_input("Second alternative guard", &d.second_guard)
.on_input(|s| event(Event::SecondGuard(s)))
.padding(9)
.size(12),
);
}
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
}
let readiness = d.command(p);
if let Err(error) = &readiness {
form = form.push(label(error.clone()));
}
let footer = row![
button("Create")
.padding(9)
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7);
form_panel = editor_panel(footer.into(), form.into());
}
workspace_layout(header.into(), details, canvas, form_panel, pending)
}
fn sequence_action<'a>(
title: &str,
message: Message,
enabled: bool,
) -> iced::widget::Button<'a, Message> {
button(text(title.to_owned()).size(12))
.padding(8)
.style(style::nav)
.on_press_maybe(enabled.then_some(message))
}
// Permanent header/shelf/body and canvas/form slots. Zero-sized disclosures
// must remain in the tree: Column::push otherwise drops them and remounts siblings.
fn workspace_layout<'a, R: iced::advanced::text::Renderer + 'a>(
header: iced::Element<'a, Message, Theme, R>,
details: iced::Element<'a, Message, Theme, R>,
canvas: iced::Element<'a, Message, Theme, R>,
form: iced::Element<'a, Message, Theme, R>,
pending: bool,
) -> iced::Element<'a, Message, Theme, R> {
let panes = Column::from_vec(vec![
container(canvas).width(Fill).height(Fill).into(),
container(form)
.width(Fill)
.height(if pending {
Fill
} else {
iced::Length::Fixed(0.)
})
.clip(true)
.into(),
])
.spacing(if pending { 6 } else { 0 })
.width(Fill)
.height(Fill);
Column::from_vec(vec![header, details, panes.into()])
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
context: Context,
restore: Option<ScrollRestore>,
details: iced::Element<'a, Message, Theme, R>,
open: bool,
pending: bool,
) -> iced::Element<'a, Message, Theme, R> {
scenario_scroll::observe(
context,
ScrollArea::Page,
restore,
scrollable(details)
.id("scenario-details")
.width(Fill)
.height(if open {
if pending { 72. } else { 100. }
} else {
0.
}),
)
}
fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>(
context: Context,
restore: Option<ScrollRestore>,
content: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
container(
sensor(scenario_scroll::observe(
context,
ScrollArea::Canvas,
restore,
scrollable(content)
.id("scenario-canvas")
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.width(Fill)
.height(Fill),
))
.key((context.document, context.scenario))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.padding(8)
.width(Fill)
.height(Fill)
.style(style::card)
.into()
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
actions: iced::Element<'a, Message, Theme, R>,
fields: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
container(
column![
container(actions).id("scenario-actions").width(Fill),
scrollable(fields)
.id("scenario-form")
.width(Fill)
.height(Fill),
]
.spacing(6),
)
.padding(10)
.width(Fill)
.height(Fill)
.style(style::card)
.into()
}
#[cfg(all(test, debug_assertions))]
mod tests;
fn sequence_rows<'a>(
p: &'a Project,
context: Context,
owner: Id,
mut out: Column<'a, Message>,
enabled: bool,
) -> Column<'a, Message> {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get(&owner).and_then(|e| e.shared())
{
for branch in branches {
out = out.push(sequence_action(
&format!("Operand: {}", p.named(*branch)),
Message::Select(*branch),
enabled,
));
out = sequence_rows(p, context, *branch, out, enabled);
}
}
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
for (i, id) in steps.iter().enumerate() {
out = out.push(
row![
sequence_action(
&format!("{}. {}", i + 1, p.named(*id)),
Message::Select(*id),
enabled
)
.width(Fill),
button("↑")
.padding(8)
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
Command::MoveStep(owner, i, i.saturating_sub(1))
))),
button("↓").padding(8).on_press_maybe(
(enabled && i + 1 < steps.len())
.then_some(Message::Command(Command::MoveStep(owner, i, i + 1)))
)
]
.spacing(6),
);
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::ScenarioUse {
scenario: Some(target),
..
},
..
}) = p.elements[id].shared()
{
out = out.push(sequence_action(
&format!("Open {} →", p.named(*target)),
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, *id)),
enabled,
));
}
out = sequence_rows(p, context, *id, out, enabled);
}
}
out
}
@@ -0,0 +1,887 @@
use super::*;
use iced::Vector;
use iced::advanced::{Layout, Shell, layout, widget};
#[derive(Default)]
struct Geometry {
scrolls: BTreeMap<String, (Rectangle, Vector)>,
actions: Option<Rectangle>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("scenario-actions")) {
self.actions = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
for name in ["scenario-canvas", "scenario-details", "scenario-form"] {
if id == Some(&widget::Id::new(name)) {
assert!(
self.scrolls
.insert(name.into(), (bounds, translation))
.is_none()
);
}
}
}
}
type TestView = iced::Element<'static, Message, Theme, ()>;
fn fixture_view(state: &State, context: Context, pending: bool, header: f32) -> TestView {
workspace_layout(
Space::new().height(header).into(),
details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
Space::new().height(1800.).into(),
state.presentation.shelf.is_some(),
pending,
),
canvas_pane(
context,
state.scroll_restore(context, ScrollArea::Canvas),
Space::new().width(1800.).height(2400.).into(),
),
editor_panel(
Space::new().height(32.).into(),
Space::new().height(2000.).into(),
),
pending,
)
}
fn geometry(view: &mut TestView, tree: &mut widget::Tree, size: Size) -> (layout::Node, Geometry) {
let node = view
.as_widget_mut()
.layout(tree, &(), &layout::Limits::new(Size::ZERO, size));
let mut geometry = Geometry::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &(), &mut geometry);
(node, geometry)
}
fn scroll(
view: &mut TestView,
tree: &mut widget::Tree,
node: &layout::Node,
name: &'static str,
offset: Vector,
) {
let mut operation = widget::operation::scrollable::scroll_to::<()>(
widget::Id::new(name),
widget::operation::scrollable::AbsoluteOffset {
x: Some(offset.x),
y: Some(offset.y),
},
);
view.as_widget_mut()
.operate(tree, Layout::new(node), &(), &mut operation);
}
fn redraw(view: &mut TestView, tree: &mut widget::Tree, size: Size, app: &mut App) {
let (node, _) = geometry(view, tree, size);
let mut messages = vec![];
view.as_widget_mut().update(
tree,
&iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
Layout::new(&node),
mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::with_size(size),
);
for message in messages {
let _ = app.update(message);
}
}
#[test]
fn scenario_workspace_allocates_remaining_height_and_pins_actions_at_minimum_and_wide_sizes() {
for size in [
Size::new(500., 570.),
Size::new(1050., 570.),
Size::new(840., 770.),
Size::new(1394., 802.),
] {
for pending in [false, true] {
for shelf in [None, Some(Shelf::Tools), Some(Shelf::Selection)] {
for header in [68., 100., 132.] {
let mut state = State::default();
state.presentation.shelf = shelf;
let mut view = fixture_view(
&state,
super::super::direct_tests::context(Id::new_v4()),
pending,
header,
);
let mut tree = widget::Tree::new(view.as_widget());
let (node, actual) = geometry(&mut view, &mut tree, size);
assert_eq!(node.size(), size);
let canvas = actual.scrolls["scenario-canvas"].0;
assert!(canvas.x >= 0. && canvas.y >= header);
assert!(
canvas.x + canvas.width <= size.width
&& canvas.y + canvas.height <= size.height
);
assert!(
canvas.height >= if pending { 145. } else { 290. },
"{size:?} header={header} pending={pending} shelf={shelf:?}: {canvas:?}"
);
if pending {
let form = actual.scrolls["scenario-form"].0;
let actions = actual.actions.unwrap();
assert!(form.height >= 100. && form.y + form.height <= size.height);
assert!(actions.y >= canvas.y + canvas.height);
assert!(actions.y + actions.height <= form.y);
scroll(
&mut view,
&mut tree,
&node,
"scenario-form",
Vector::new(0., 600.),
);
let (_, after) = geometry(&mut view, &mut tree, size);
assert_eq!(after.actions, Some(actions));
assert_eq!(after.scrolls["scenario-form"].1.y, 600.);
assert_eq!(after.scrolls["scenario-canvas"].1, Vector::ZERO);
}
}
}
}
}
}
#[test]
fn scenario_workspace_canvas_and_form_scroll_survive_disclosures_focus_width_and_resize() {
let context = super::super::direct_tests::context(Id::new_v4());
let mut state = State::default();
let mut view = fixture_view(&state, context, false, 68.);
let mut tree = widget::Tree::new(view.as_widget());
let (node, _) = geometry(&mut view, &mut tree, Size::new(500., 570.));
let offset = Vector::new(90., 180.);
scroll(&mut view, &mut tree, &node, "scenario-canvas", offset);
let mut prior_form = false;
for (pending, shelf, header, size) in [
(false, Some(Shelf::Tools), 100., Size::new(500., 570.)),
(true, Some(Shelf::Selection), 68., Size::new(1050., 570.)),
(true, None, 68., Size::new(1394., 802.)),
(true, Some(Shelf::Tools), 100., Size::new(500., 570.)),
(false, None, 100., Size::new(500., 570.)),
] {
state.presentation.shelf = shelf;
view = fixture_view(&state, context, pending, header);
tree.diff(view.as_widget());
let (node, actual) = geometry(&mut view, &mut tree, size);
assert_eq!(actual.scrolls["scenario-canvas"].1, offset);
if pending {
if prior_form {
assert_eq!(actual.scrolls["scenario-form"].1.y, 600.);
}
scroll(
&mut view,
&mut tree,
&node,
"scenario-form",
Vector::new(0., 600.),
);
let (_, after) = geometry(&mut view, &mut tree, size);
assert_eq!(after.actions, actual.actions);
assert_eq!(after.scrolls["scenario-canvas"].1, offset);
}
prior_form = pending;
}
}
#[test]
fn scenario_workspace_caller_restores_actual_mounted_canvas_and_shelf_with_camera() {
use super::super::direct_tests::{navigation_event, navigation_fixture};
let (p, [a, b, _], [reference, _, _]) = navigation_fixture();
let mut app = App::with_project(p);
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection));
navigation_event(&mut app, CanvasEvent::Focus);
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75)));
let source = app.document.snapshot();
let size = Size::new(1050., 570.);
let mut view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
false,
68.,
);
let mut tree = widget::Tree::new(view.as_widget());
redraw(&mut view, &mut tree, size, &mut app);
let (node, _) = geometry(&mut view, &mut tree, size);
let canvas_offset = Vector::new(90., 180.);
scroll(
&mut view,
&mut tree,
&node,
"scenario-canvas",
canvas_offset,
);
scroll(
&mut view,
&mut tree,
&node,
"scenario-details",
Vector::new(0., 80.),
);
redraw(&mut view, &mut tree, size, &mut app);
assert_eq!(
app.behavior.scenario.scroll_offset(),
canvas_offset,
"shelf is not a canvas ancestor"
);
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
assert_eq!(app.selected, b);
view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
false,
68.,
);
tree.diff(view.as_widget());
redraw(&mut view, &mut tree, size, &mut app);
let (_, opened) = geometry(&mut view, &mut tree, size);
assert_eq!(opened.scrolls["scenario-canvas"].1, Vector::ZERO);
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(2.)));
navigation_event(&mut app, CanvasEvent::Focus);
navigation_event(&mut app, CanvasEvent::Back);
assert_eq!(app.selected, reference);
assert!(app.behavior.scenario.focused());
assert_eq!(
app.behavior.scenario.presentation.shelf,
Some(Shelf::Selection)
);
assert_eq!(
app.behavior
.scenario
.presentation
.scale(Size::new(800., 1200.)),
0.75
);
view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
false,
68.,
);
tree.diff(view.as_widget());
redraw(&mut view, &mut tree, size, &mut app);
let (node, returned) = geometry(&mut view, &mut tree, size);
assert_eq!(returned.scrolls["scenario-canvas"].1, canvas_offset);
assert_eq!(returned.scrolls["scenario-details"].1, Vector::new(0., 80.));
for area in [ScrollArea::Canvas, ScrollArea::Page] {
assert!(
app.behavior
.scenario
.scroll_restore(app.scenario_context().unwrap(), area)
.is_none()
);
}
scroll(
&mut view,
&mut tree,
&node,
"scenario-canvas",
Vector::new(90., 220.),
);
redraw(&mut view, &mut tree, size, &mut app);
assert_eq!(
geometry(&mut view, &mut tree, size).1.scrolls["scenario-canvas"]
.1
.y,
220.,
"receipt cannot snap a later gesture back"
);
assert!(std::sync::Arc::ptr_eq(&source, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
#[test]
fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inputs() {
let graph = Size::new(900., 1400.);
let mut presentation = Presentation::default();
assert_eq!(presentation.scale(graph), 1.);
presentation.zoom(None);
assert_eq!(
presentation.scale(graph),
1.,
"no invented window-height fallback"
);
assert!(presentation.viewport(Size::new(484., 204.)));
assert!(!presentation.viewport(Size::new(484., 204.)));
let scale = presentation.scale(graph);
assert!(graph.width * scale <= 468. && graph.height * scale <= 188.);
assert!(!presentation.fit_limited(graph));
for bad in [f32::NAN, f32::INFINITY, 0., -1.] {
assert!(!presentation.zoom(Some(bad)));
assert!(!presentation.viewport(Size::new(bad, 100.)));
assert!(!presentation.viewport(Size::new(100., bad)));
assert_eq!(presentation.scale(graph), scale);
}
assert!(presentation.fit_limited(Size::new(900., 10000.)));
assert!(presentation.zoom(Some(1.25)));
presentation.viewport(Size::new(200., 100.));
assert_eq!(
presentation.scale(graph),
1.25,
"manual zoom survives form/viewport resize"
);
}
#[test]
fn scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewport() {
use super::super::direct_tests::{navigation_event, navigation_fixture};
let (project, [a, b, _], [reference, _, _]) = navigation_fixture();
let mut app = App::with_project(project.clone());
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let size = Size::new(500., 570.);
let graph = Size::new(1800., 2400.);
let mut view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
false,
68.,
);
let mut tree = widget::Tree::new(view.as_widget());
redraw(&mut view, &mut tree, size, &mut app);
let first_viewport = app.behavior.scenario.presentation.viewport.unwrap();
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
assert!(app.behavior.scenario.presentation.scale(graph) < 1.);
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
assert_eq!(app.scenario_context().unwrap().scenario, b);
assert!(app.behavior.scenario.presentation.viewport.is_none());
view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
false,
68.,
);
tree.diff(view.as_widget());
redraw(&mut view, &mut tree, size, &mut app);
assert_eq!(
app.behavior.scenario.presentation.viewport,
Some(first_viewport)
);
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
let fitted = app.behavior.scenario.presentation.scale(graph);
assert!(graph.width * fitted <= first_viewport.width - 16.);
assert!(graph.height * fitted <= first_viewport.height - 16.);
let prior_document = app.scenario_context().unwrap().document;
app = App::with_project(project);
let _ = app.update(Message::Select(b));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let context = app.scenario_context().unwrap();
assert_ne!(context.document, prior_document);
assert_eq!(
context.scenario, b,
"semantic UUID is retained across documents"
);
assert!(app.behavior.scenario.presentation.viewport.is_none());
let accepted = app.document.snapshot();
view = fixture_view(&app.behavior.scenario, context, false, 68.);
tree.diff(view.as_widget());
redraw(&mut view, &mut tree, size, &mut app);
assert_eq!(
app.behavior.scenario.presentation.viewport,
Some(first_viewport)
);
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
assert_eq!(app.behavior.scenario.presentation.scale(graph), fitted);
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
#[test]
fn scenario_workspace_open_and_back_restore_owner_and_mounted_scroll_after_preselection() {
use super::super::direct_tests::{navigation_event, navigation_fixture};
let (p, [a, b, _], [reference, _, _]) = navigation_fixture();
let mut app = App::with_project(p);
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
navigation_event(&mut app, |ctx| {
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
});
navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection));
navigation_event(&mut app, CanvasEvent::Focus);
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75)));
let size = Size::new(1050., 570.);
let mut view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
false,
68.,
);
let mut tree = widget::Tree::new(view.as_widget());
redraw(&mut view, &mut tree, size, &mut app);
let (node, _) = geometry(&mut view, &mut tree, size);
let offset = Vector::new(90., 180.);
scroll(&mut view, &mut tree, &node, "scenario-canvas", offset);
scroll(
&mut view,
&mut tree,
&node,
"scenario-details",
Vector::new(0., 80.),
);
redraw(&mut view, &mut tree, size, &mut app);
let _ = app.update(Message::Command(Command::Rename(
b,
"Renamed definition".into(),
)));
assert_eq!(app.selected, b);
assert_eq!(app.scenario_context().unwrap().scenario, a);
let before_open = app.document.snapshot();
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
assert_eq!(app.selected, b);
assert_eq!(app.scenario_context().unwrap().scenario, b);
assert!(std::sync::Arc::ptr_eq(
&before_open,
&app.document.snapshot()
));
view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
false,
68.,
);
tree.diff(view.as_widget());
redraw(&mut view, &mut tree, size, &mut app);
let (_, opened) = geometry(&mut view, &mut tree, size);
assert_eq!(opened.scrolls["scenario-canvas"].1, Vector::ZERO);
let _ = app.update(Message::Command(Command::Rename(
reference,
"Renamed caller use".into(),
)));
assert_eq!(app.selected, reference);
assert_eq!(app.scenario_context().unwrap().scenario, b);
let before_back = app.document.snapshot();
let revision = app.document.revision();
navigation_event(&mut app, CanvasEvent::Back);
assert_eq!(app.selected, reference);
assert_eq!(app.scenario_context().unwrap().scenario, a);
assert_eq!(
app.behavior.scenario.canvas_tool(a),
CanvasTool::Message(MessageStyle::Timer)
);
assert!(app.behavior.scenario.focused());
assert_eq!(
app.behavior.scenario.presentation.shelf,
Some(Shelf::Selection)
);
assert_eq!(app.behavior.scenario.presentation.zoom, Some(0.75));
view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
false,
68.,
);
tree.diff(view.as_widget());
redraw(&mut view, &mut tree, size, &mut app);
let (_, restored) = geometry(&mut view, &mut tree, size);
assert_eq!(restored.scrolls["scenario-canvas"].1, offset);
assert_eq!(restored.scrolls["scenario-details"].1, Vector::new(0., 80.));
assert_eq!(app.behavior.scenario.scroll_offset(), offset);
assert!(app.behavior.scenario.restore.is_none());
assert!(std::sync::Arc::ptr_eq(
&before_back,
&app.document.snapshot()
));
assert_eq!(app.document.revision(), revision);
assert!(app.document.can_undo());
assert!(!app.document.can_redo());
}
#[test]
fn scenario_workspace_viewport_events_are_passive_guarded_and_do_not_apply_raw_forms() {
use super::super::direct_tests::{fixture, navigation_event};
let f = fixture();
let mut app = App::with_project(f.project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let _ = app.update(event(Event::Start(f.owner, Tool::Message)));
let _ = app.update(event(Event::Name(" raw 雨 ".into())));
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
let context = app.scenario_context().unwrap();
app.busy = true;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
Size::new(500., 200.),
)));
assert_ne!(app.scenario_context(), Some(context));
let epoch = app.canvas_epoch;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
Size::new(500., 200.),
)));
assert_eq!(app.canvas_epoch, epoch);
for mismatch in 0..3 {
let mut stale = context;
match mismatch {
0 => stale.document = Id::new_v4(),
1 => stale.scenario = Id::new_v4(),
_ => stale.revision += 1,
}
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
stale,
Size::new(900., 400.),
)));
assert_eq!(app.canvas_epoch, epoch);
}
app.busy = false;
navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Tools));
navigation_event(&mut app, CanvasEvent::Focus);
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
#[test]
fn scenario_workspace_choosing_tools_preserves_valid_and_invalid_pending_editors() {
use super::super::direct_tests::{fixture, navigation_event};
for editor in 0..3 {
for valid in [false, true] {
let f = fixture();
let mut app = App::with_project(f.project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
match editor {
0 => {
let _ = app.update(Message::Field(
if valid { "/name" } else { "/id" }.into(),
if valid {
" Retained property 雨 "
} else {
"not-a-uuid"
}
.into(),
));
assert!(app.draft_dirty());
assert_eq!(app.draft.as_ref().unwrap().command().is_ok(), valid);
}
1 => {
let raw = if valid {
format!("// Retained source 雨\n{}", app.source_editor.text())
} else {
"? invalid source 雨".into()
};
let _ = app.update(Message::SourceEdit(
iced::widget::text_editor::Action::SelectAll,
));
let _ = app.update(Message::SourceEdit(
iced::widget::text_editor::Action::Edit(
iced::widget::text_editor::Edit::Paste(std::sync::Arc::new(raw)),
),
));
assert!(app.source_dirty());
assert_eq!(
crate::source_workspace::compile(&app.source_workspace.bundle()).is_ok(),
valid
);
}
_ => {
let _ = app.update(event(Event::Start(
f.owner,
if valid { Tool::Fragment } else { Tool::Action },
)));
let _ = app.update(event(Event::Name(" Retained behavior 雨 ".into())));
let _ = app.update(event(Event::Expression("ready".into())));
let _ = app.update(event(Event::SecondGuard("otherwise".into())));
assert!(app.behavior.has_pending());
assert_eq!(
app.behavior
.scenario
.draft
.as_ref()
.unwrap()
.command(app.project())
.is_ok(),
valid
);
}
}
let source = serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap();
let properties = app
.draft
.as_ref()
.map(|draft| serde_json::to_value(draft.recovery_snapshot()).unwrap());
let behavior = app.behavior.recovery_snapshot().unwrap();
let text = app.source_editor.text();
let accepted = app.document.snapshot();
let selected = app.selected;
let context = app.scenario_context();
let tool = app.behavior.scenario.canvas_tool(f.owner);
navigation_event(&mut app, |ctx| {
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
});
assert!(
app.status.contains("Apply or cancel"),
"editor={editor} valid={valid}: {}",
app.status
);
assert_eq!(app.source_editor.text(), text);
assert_eq!(
serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(),
source
);
assert_eq!(
app.draft
.as_ref()
.map(|draft| serde_json::to_value(draft.recovery_snapshot()).unwrap()),
properties
);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), behavior);
assert_eq!(app.selected, selected);
assert_eq!(app.scenario_context(), context);
assert_eq!(app.behavior.scenario.canvas_tool(f.owner), tool);
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
assert!(!app.document.can_redo());
}
}
}
#[test]
fn scenario_workspace_whole_app_canvas_tree_survives_pending_shelves_and_focus() {
use iced::advanced::widget::tree;
use std::rc::Rc;
fn path_to(tree: &widget::Tree, tag: tree::Tag) -> Option<Vec<usize>> {
if tree.tag == tag {
return Some(vec![]);
}
tree.children.iter().enumerate().find_map(|(i, child)| {
let mut path = path_to(child, tag)?;
path.insert(0, i);
Some(path)
})
}
fn at<'a>(mut tree: &'a mut widget::Tree, path: &[usize]) -> &'a mut widget::Tree {
for &index in path {
tree = &mut tree.children[index];
}
tree
}
let f = super::super::direct_tests::fixture();
let mut app = App::with_project(f.project);
let _ = app.update(Message::Select(f.call));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let canvas: Element<'_, Message> =
canvas::Canvas::new(Scene::sequence(app.project(), f.owner, f.call)).into();
let tag = widget::Tree::new(canvas.as_widget()).tag;
drop(canvas);
let mut tree = widget::Tree::new(crate::views::view(&app).as_widget());
let mut path = path_to(&tree, tag).unwrap();
path.pop();
let marker = Rc::new(());
at(&mut tree, &path).state = tree::State::new(Rc::clone(&marker));
let _ = app.update(event(Event::Start(f.owner, Tool::Message)));
let _ = app.update(event(Event::Name(" untouched 雨 ".into())));
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
for shelf in [Shelf::Tools, Shelf::Selection, Shelf::Selection] {
super::super::direct_tests::navigation_event(&mut app, |ctx| {
CanvasEvent::Shelf(ctx, shelf)
});
super::super::direct_tests::navigation_event(&mut app, CanvasEvent::Focus);
tree.diff(crate::views::view(&app).as_widget());
let mut current = path_to(&tree, tag).unwrap();
current.pop();
assert_eq!(current, path);
assert_eq!(Rc::strong_count(&marker), 2);
assert!(Rc::ptr_eq(
at(&mut tree, &path).state.downcast_ref::<Rc<()>>(),
&marker
));
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert_eq!(app.selected, f.call);
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn scenario_workspace_scaled_and_scrolled_scene_keeps_clicked_slots_and_message_endpoints() {
let f = super::super::direct_tests::fixture();
for scale in [0.25, 0.5, 1., 2.] {
for drag in [false, true] {
let mut scene = Scene::sequence(&f.project, f.owner, f.call);
let context = super::super::direct_tests::context(f.owner);
scene.scale = scale;
scene.scroll_offset = Vector::new(35., 40.);
scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true));
let slot = scene.slots.last().unwrap();
let expected = slot.slot;
let bounds = Rectangle::new(
Point::new(20., 80.),
Size::new(scene.size.width * scale, scene.size.height * scale),
);
let screen = |world: Point| {
Point::new(bounds.x + world.x * scale, bounds.y + world.y * scale)
- scene.scroll_offset
};
let a = screen(Point::new(scene.lanes[&f.sender], slot.y));
let b = screen(Point::new(scene.lanes[&f.receiver], slot.y));
let mut input = super::super::super::SequenceInput::default();
let mut messages = vec![];
let mut events = vec![
(mouse::Event::CursorMoved { position: a }, a),
(mouse::Event::ButtonPressed(mouse::Button::Left), a),
];
if !drag {
events.push((mouse::Event::ButtonReleased(mouse::Button::Left), a));
}
events.push((mouse::Event::CursorMoved { position: b }, b));
if !drag {
events.push((mouse::Event::ButtonPressed(mouse::Button::Left), b));
}
events.push((mouse::Event::ButtonReleased(mouse::Button::Left), b));
for (event, cursor) in events {
if let Some(action) = canvas::Program::update(
&scene,
&mut input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(cursor + scene.scroll_offset),
) && let Some(message) = action.into_inner().0
{
messages.push(message);
}
}
assert!(
matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Begin { sender, receiver, slot, .. })]
if *sender == f.sender && *receiver == f.receiver && *slot == expected),
"scale={scale} drag={drag}"
);
}
}
}
#[test]
fn scenario_workspace_scaled_nested_occurrences_and_reorder_keep_exact_sequence_identity() {
let (f, branch, anchor, _, _, _) = super::super::direct_tests::placement_fixture();
for scale in [0.25, 0.5, 1., 2.] {
let mut scene = Scene::sequence(&f.project, f.owner, f.call);
let context = super::super::direct_tests::context(f.owner);
scene.scale = scale;
scene.scroll_offset = Vector::new(35., 40.);
let bounds = Rectangle::new(
Point::new(20., 80.),
Size::new(scene.size.width * scale, scene.size.height * scale),
);
let screen = |world: Point| {
Point::new(bounds.x + world.x * scale, bounds.y + world.y * scale)
- Vector::new(35., 40.)
};
let send =
|scene: &Scene, input: &mut super::super::super::SequenceInput, event, cursor| {
canvas::Program::update(
scene,
input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(cursor + scene.scroll_offset),
)
.and_then(|action| action.into_inner().0)
};
for tool in [Tool::Action, Tool::Observation, Tool::Reuse, Tool::Fragment] {
scene.interaction = Some((context, CanvasTool::Add(tool), true));
for expected in [
Slot {
parent: f.owner,
before: Some(f.reply),
},
Slot {
parent: branch,
before: Some(anchor),
},
] {
let slot = scene
.slots
.iter()
.find(|slot| slot.slot == expected)
.unwrap();
let point = screen(Point::new(scene.lanes[&f.sender], slot.y));
let mut input = super::super::super::SequenceInput::default();
send(
&scene,
&mut input,
mouse::Event::CursorMoved { position: point },
point,
);
assert!(
send(
&scene,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point
)
.is_none()
);
let message = send(
&scene,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
point,
)
.unwrap();
assert!(
matches!(message, Message::ScenarioCanvas(CanvasEvent::BeginOccurrence { tool: captured, slot, lifeline, .. })
if captured == tool && slot == expected && lifeline == tool.uses_lifeline().then_some(f.sender)),
"scale={scale} tool={tool:?} slot={expected:?}"
);
}
}
scene.interaction = Some((context, CanvasTool::Select, true));
let mut input = super::super::super::SequenceInput::default();
let expected = scene
.slots
.iter()
.rev()
.find(|slot| slot.slot.parent == f.owner)
.unwrap();
let start = screen(scene.reorder_handle().unwrap().center());
let end = screen(Point::new(17., expected.y));
send(
&scene,
&mut input,
mouse::Event::CursorMoved { position: start },
start,
);
send(
&scene,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
start,
);
send(
&scene,
&mut input,
mouse::Event::CursorMoved { position: end },
end,
);
assert!(
matches!(send(&scene, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), end),
Some(Message::ScenarioCanvas(CanvasEvent::Reorder { occurrence, slot, .. }))
if occurrence == f.call && slot == expected.slot),
"scale={scale}"
);
}
}
@@ -0,0 +1,283 @@
//! Read-only lifecycle geometry. Operand screen order is never execution order.
use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
/// Maps already recorded by the sequence renderer. Rows carry their owning sequence.
pub(crate) struct Geometry<'a> {
pub rows: &'a BTreeMap<Id, (Id, f32)>,
pub scopes: &'a BTreeMap<Id, (f32, f32)>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Span {
pub start: f32,
pub end: f32,
}
impl Span {
fn valid(self) -> bool {
self.start.is_finite() && self.end.is_finite() && self.start <= self.end
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Life {
Alive,
Absent,
Unresolved,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Interval {
pub scope: Id,
pub span: Span,
pub life: Life,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MarkerKind {
Birth,
Death,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Marker {
pub occurrence: Id,
pub scope: Id,
pub y: f32,
pub kind: MarkerKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UncertaintyReason {
Conditional,
Repeated,
Parallel,
MissingGeometry,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Uncertainty {
pub scope: Id,
pub reason: UncertaintyReason,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct LaneLifetime {
pub lifeline: Id,
pub intervals: Vec<Interval>,
pub markers: Vec<Marker>,
pub uncertainties: Vec<Uncertainty>,
}
/// Stable lane-ID order; source/model/history and caller-owned geometry are untouched.
/// An opaque ScenarioUse is a local use, not a caller birth/death contract.
pub(crate) fn project_lifetimes(
project: &Project,
scenario: Id,
geometry: &Geometry<'_>,
) -> Vec<LaneLifetime> {
if project
.elements
.get(&scenario)
.is_none_or(|e| e.kind() != Kind::Scenario)
{
return vec![];
}
project
.elements
.values()
.filter(|e| e.kind() == Kind::Lifeline && e.parent == Some(scenario))
.map(|e| {
let mut projection = Projection {
project,
geometry,
lane: e.id,
visiting: BTreeSet::new(),
result: LaneLifetime {
lifeline: e.id,
intervals: vec![],
markers: vec![],
uncertainties: vec![],
},
};
let initial = if projection.contains(scenario, Some(MarkerKind::Birth)) {
Life::Unresolved
} else {
Life::Alive
};
projection.sequence(scenario, initial);
projection.result
})
.collect()
}
struct Projection<'a, 'g> {
project: &'a Project,
geometry: &'a Geometry<'g>,
lane: Id,
visiting: BTreeSet<Id>,
result: LaneLifetime,
}
impl Projection<'_, '_> {
fn body(&self, id: Id) -> Option<&OccurrenceBody> {
match self.project.elements.get(&id)?.shared()? {
SharedBody::Occurrence { body, .. } => Some(body),
_ => None,
}
}
fn event(&self, id: Id) -> Option<MarkerKind> {
match self.body(id)? {
OccurrenceBody::Exchange {
receiver: Some(line),
message_kind,
..
} if *line == self.lane => match message_kind {
MessageKind::Create => Some(MarkerKind::Birth),
MessageKind::Delete => Some(MarkerKind::Death),
_ => None,
},
_ => None,
}
}
fn contains(&self, scope: Id, kind: Option<MarkerKind>) -> bool {
// Local structural walk only: no guards, iteration simulation or reuse expansion.
let mut pending = vec![scope];
let mut seen = BTreeSet::new();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
if self
.event(id)
.is_some_and(|event| kind.is_none_or(|k| event == k))
{
return true;
}
if let Some(steps) = self.project.elements.get(&id).and_then(|e| e.steps()) {
pending.extend(steps);
}
if let Some(OccurrenceBody::Fragment { branches, .. }) = self.body(id) {
pending.extend(branches);
}
}
false
}
fn span(&self, scope: Id) -> Option<Span> {
let &(start, end) = self.geometry.scopes.get(&scope)?;
let span = Span { start, end };
span.valid().then_some(span)
}
fn uncertain(&mut self, scope: Id, reason: UncertaintyReason) {
let value = Uncertainty { scope, reason };
if !self.result.uncertainties.contains(&value) {
self.result.uncertainties.push(value);
}
}
fn interval(&mut self, scope: Id, start: f32, end: f32, life: Life) {
if start < end && start.is_finite() && end.is_finite() {
self.result.intervals.push(Interval {
scope,
span: Span { start, end },
life,
});
}
}
fn sequence(&mut self, scope: Id, entry: Life) -> Life {
let Some(span) = self.span(scope) else {
self.uncertain(scope, UncertaintyReason::MissingGeometry);
return Life::Unresolved;
};
if !self.visiting.insert(scope) {
self.uncertain(scope, UncertaintyReason::MissingGeometry);
return Life::Unresolved;
}
let steps = self
.project
.elements
.get(&scope)
.and_then(|e| e.steps())
.cloned()
.unwrap_or_default();
// A creation directly in this sequence fixes its local birth boundary.
// A creation only in a nested operand cannot fix its ancestor's entry.
let mut life = if steps
.iter()
.any(|&id| self.event(id) == Some(MarkerKind::Birth))
{
Life::Absent
} else {
entry
};
let mut cursor = span.start;
for id in steps {
let event = self.event(id);
let fragment = match self.body(id) {
Some(OccurrenceBody::Fragment { operator, branches }) => {
Some((*operator, branches.clone()))
}
_ => None,
};
if event.is_none() && fragment.is_none() {
continue;
}
let y = self
.geometry
.rows
.get(&id)
.filter(|(owner, y)| {
*owner == scope && y.is_finite() && *y >= cursor && *y <= span.end
})
.map(|(_, y)| *y);
let Some(y) = y else {
self.uncertain(id, UncertaintyReason::MissingGeometry);
life = Life::Unresolved;
continue;
};
self.interval(scope, cursor, y, life);
cursor = y;
if let Some(kind) = event {
self.result.markers.push(Marker {
occurrence: id,
scope,
y,
kind,
});
life = match kind {
MarkerKind::Birth => Life::Alive,
MarkerKind::Death => Life::Absent,
};
}
if let Some((operator, branches)) = fragment {
let entry = life;
let mut missing = false;
for branch in branches {
let bounds = self
.span(branch)
.filter(|s| s.start >= cursor && s.end <= span.end);
if let Some(bounds) = bounds {
// Every sibling inherits the same entry, never its displayed predecessor's exit.
self.sequence(branch, entry);
cursor = bounds.end;
} else {
self.uncertain(branch, UncertaintyReason::MissingGeometry);
missing = true;
}
}
if self.contains(id, None) {
let reason = match operator {
FragmentOperator::Alternative | FragmentOperator::Optional => {
UncertaintyReason::Conditional
}
FragmentOperator::Loop => UncertaintyReason::Repeated,
FragmentOperator::Parallel => UncertaintyReason::Parallel,
};
self.uncertain(id, reason);
life = Life::Unresolved;
} else if missing {
life = Life::Unresolved;
}
}
}
self.interval(scope, cursor, span.end, life);
self.visiting.remove(&scope);
life
}
}
#[cfg(test)]
#[path = "scenario_lifecycle/tests.rs"]
mod tests;
@@ -0,0 +1,470 @@
use super::*;
use oa_core::*;
struct Fixture {
p: Project,
owner: Id,
a: Id,
b: Id,
exchange: Id,
rows: BTreeMap<Id, (Id, f32)>,
scopes: BTreeMap<Id, (f32, f32)>,
}
impl Fixture {
fn new(layer: Layer) -> Self {
let mut p = Project::empty();
let kind = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let a = p.add_element(kind, layer, "Request", None).unwrap();
let b = p.add_element(kind, layer, "Provide", None).unwrap();
let owner = p
.add_element(Kind::Scenario, layer, "Lifecycle", None)
.unwrap();
let exchange = p.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"Service",
a,
b,
);
let line = |p: &mut Project, represented| {
let id = p
.add_element(Kind::Lifeline, layer, "Participant", Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
id
};
let a = line(&mut p, a);
let b = line(&mut p, b);
Self {
p,
owner,
a,
b,
exchange,
rows: BTreeMap::new(),
scopes: [(owner, (0., 600.))].into(),
}
}
fn occurrence(&mut self, owner: Id, y: f32, body: OccurrenceBody) -> Id {
let mut e = self
.p
.make_element(
Kind::Occurrence,
self.p.elements[&owner].layer(),
"Occurrence",
Some(owner),
)
.unwrap();
let id = e.id;
*e.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
self.p.elements.insert(id, e);
if let Some(steps) = self.p.elements.get_mut(&owner).unwrap().steps_mut() {
steps.push(id);
} else if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = self.p.elements.get_mut(&owner).unwrap().shared_mut()
{
branches.push(id);
}
self.rows.insert(id, (owner, y));
id
}
fn message(&mut self, owner: Id, y: f32, kind: MessageKind) -> Id {
self.occurrence(
owner,
y,
OccurrenceBody::Exchange {
exchange: Some(self.exchange),
sender: Some(self.a),
receiver: Some(self.b),
message_kind: kind,
operation: None,
reply_to: None,
values: vec![],
},
)
}
fn fragment(&mut self, owner: Id, y: f32, operator: FragmentOperator) -> Id {
self.occurrence(
owner,
y,
OccurrenceBody::Fragment {
operator,
branches: vec![],
},
)
}
fn branch(&mut self, fragment: Id, start: f32, end: f32) -> Id {
let id = self.occurrence(
fragment,
start,
OccurrenceBody::Branch {
guard: "condition".into(),
occurrences: vec![],
},
);
self.scopes.insert(id, (start, end));
id
}
fn project(&self) -> Vec<LaneLifetime> {
let errors: Vec<_> = validation::validate(&self.p)
.into_iter()
.filter(|i| i.severity == validation::Severity::Error)
.collect();
assert!(
errors.is_empty(),
"fixture must be ordinarily admitted: {errors:?}"
);
let before = self.p.clone();
let source = source_project::format_bundle(&self.p);
let geometry = Geometry {
rows: &self.rows,
scopes: &self.scopes,
};
let result = project_lifetimes(&self.p, self.owner, &geometry);
assert_eq!(result, project_lifetimes(&self.p, self.owner, &geometry));
assert_eq!(self.p, before);
assert_eq!(source_project::format_bundle(&self.p), source);
assert!(
result
.windows(2)
.all(|pair| pair[0].lifeline < pair[1].lifeline)
);
result
}
fn lane(&self) -> LaneLifetime {
self.project()
.into_iter()
.find(|lane| lane.lifeline == self.b)
.unwrap()
}
}
fn intervals(lane: &LaneLifetime, scope: Id) -> Vec<(f32, f32, Life)> {
lane.intervals
.iter()
.filter(|i| i.scope == scope)
.map(|i| (i.span.start, i.span.end, i.life))
.collect()
}
#[test]
fn root_lifetime_has_exact_birth_death_boundaries_without_source_mutation() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let create = f.message(f.owner, 100., MessageKind::Create);
f.message(f.owner, 200., MessageKind::Asynchronous);
let delete = f.message(f.owner, 300., MessageKind::Delete);
let lane = f.lane();
assert_eq!(
intervals(&lane, f.owner),
vec![
(0., 100., Life::Absent),
(100., 300., Life::Alive),
(300., 600., Life::Absent)
]
);
assert_eq!(
lane.markers,
vec![
Marker {
occurrence: create,
scope: f.owner,
y: 100.,
kind: MarkerKind::Birth
},
Marker {
occurrence: delete,
scope: f.owner,
y: 300.,
kind: MarkerKind::Death
},
]
);
let sender = f
.project()
.into_iter()
.find(|lane| lane.lifeline == f.a)
.unwrap();
assert_eq!(intervals(&sender, f.owner), vec![(0., 600., Life::Alive)]);
}
}
#[test]
fn alternative_death_does_not_kill_its_displayed_sibling() {
let mut f = Fixture::new(Layer::Sa);
let fragment = f.fragment(f.owner, 100., FragmentOperator::Alternative);
let left = f.branch(fragment, 120., 250.);
let right = f.branch(fragment, 300., 450.);
f.message(left, 200., MessageKind::Delete);
f.message(right, 350., MessageKind::Asynchronous);
let lane = f.lane();
assert_eq!(
intervals(&lane, left),
vec![(120., 200., Life::Alive), (200., 250., Life::Absent)]
);
assert_eq!(intervals(&lane, right), vec![(300., 450., Life::Alive)]);
assert_eq!(
intervals(&lane, f.owner),
vec![(0., 100., Life::Alive), (450., 600., Life::Unresolved)]
);
assert_eq!(
lane.uncertainties,
vec![Uncertainty {
scope: fragment,
reason: UncertaintyReason::Conditional
}]
);
}
#[test]
fn conditional_birth_does_not_infer_absence_in_siblings_or_ancestors() {
for operator in [FragmentOperator::Alternative, FragmentOperator::Optional] {
let mut f = Fixture::new(Layer::Oa);
let fragment = f.fragment(f.owner, 100., operator);
let left = f.branch(fragment, 120., 250.);
let right = f.branch(fragment, 300., 450.);
f.message(left, 200., MessageKind::Create);
let lane = f.lane();
assert_eq!(
intervals(&lane, left),
vec![(120., 200., Life::Absent), (200., 250., Life::Alive)]
);
assert_eq!(
intervals(&lane, right),
vec![(300., 450., Life::Unresolved)]
);
assert_eq!(
intervals(&lane, f.owner),
vec![(0., 100., Life::Unresolved), (450., 600., Life::Unresolved)]
);
}
}
#[test]
fn loop_lifetime_is_local_and_warning_bearing_loops_remain_drawable() {
for balanced in [true, false] {
let mut f = Fixture::new(Layer::Sa);
let fragment = f.fragment(f.owner, 100., FragmentOperator::Loop);
let iteration = f.branch(fragment, 120., 450.);
let create = f.message(iteration, 160., MessageKind::Create);
f.message(iteration, 250., MessageKind::Asynchronous);
if balanced {
f.message(iteration, 350., MessageKind::Delete);
}
let lane = f.lane();
assert_eq!(lane.markers.len(), if balanced { 2 } else { 1 });
assert_eq!(lane.markers[0].occurrence, create);
assert!(lane.uncertainties.contains(&Uncertainty {
scope: fragment,
reason: UncertaintyReason::Repeated
}));
assert_eq!(
intervals(&lane, f.owner).last().unwrap().2,
Life::Unresolved
);
assert_eq!(
validation::validate(&f.p)
.iter()
.any(|i| i.code == "lifeline_repetition"),
!balanced
);
}
}
#[test]
fn optional_cleanup_inside_loop_does_not_claim_balanced_or_definite_exit() {
let mut f = Fixture::new(Layer::Sa);
let repeated = f.fragment(f.owner, 100., FragmentOperator::Loop);
let iteration = f.branch(repeated, 120., 500.);
f.message(iteration, 160., MessageKind::Create);
let optional = f.fragment(iteration, 280., FragmentOperator::Optional);
let cleanup = f.branch(optional, 320., 400.);
f.message(cleanup, 350., MessageKind::Delete);
let lane = f.lane();
assert_eq!(
intervals(&lane, cleanup),
vec![(320., 350., Life::Alive), (350., 400., Life::Absent)]
);
assert_eq!(
intervals(&lane, iteration),
vec![
(120., 160., Life::Absent),
(160., 280., Life::Alive),
(400., 500., Life::Unresolved)
]
);
assert!(
validation::validate(&f.p)
.iter()
.any(|i| i.code == "lifeline_repetition")
);
assert!(lane.uncertainties.contains(&Uncertainty {
scope: optional,
reason: UncertaintyReason::Conditional
}));
assert!(lane.uncertainties.contains(&Uncertainty {
scope: repeated,
reason: UncertaintyReason::Repeated
}));
}
#[test]
fn lifecycle_free_fragments_do_not_poison_other_lanes() {
let mut f = Fixture::new(Layer::Oa);
let fragment = f.fragment(f.owner, 100., FragmentOperator::Parallel);
let left = f.branch(fragment, 120., 250.);
let right = f.branch(fragment, 300., 450.);
f.message(left, 200., MessageKind::Delete);
let result = f.project();
let sender = result.iter().find(|lane| lane.lifeline == f.a).unwrap();
assert!(sender.intervals.iter().all(|i| i.life == Life::Alive));
assert!(sender.uncertainties.is_empty());
let target = result.iter().find(|lane| lane.lifeline == f.b).unwrap();
assert!(target.uncertainties.contains(&Uncertainty {
scope: fragment,
reason: UncertaintyReason::Parallel
}));
assert_eq!(intervals(target, right), vec![(300., 450., Life::Alive)]);
}
#[test]
fn explicit_receiver_is_authoritative_even_with_missing_exchange() {
let mut f = Fixture::new(Layer::Oa);
let create = f.message(f.owner, 100., MessageKind::Create);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = f.p.elements.get_mut(&create).unwrap().shared_mut()
{
*exchange = None;
}
assert_eq!(f.lane().markers[0].occurrence, create);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
receiver, exchange, ..
},
..
}) = f.p.elements.get_mut(&create).unwrap().shared_mut()
{
*receiver = None;
*exchange = Some(f.exchange);
}
assert!(
f.lane().markers.is_empty(),
"exchange endpoints never invent a receiver"
);
}
#[test]
fn missing_geometry_is_reported_without_nonfinite_or_guessed_markers() {
let mut f = Fixture::new(Layer::Oa);
let create = f.message(f.owner, 100., MessageKind::Create);
f.rows.insert(create, (f.owner, f32::NAN));
let lane = f.lane();
assert!(lane.markers.is_empty());
assert_eq!(
lane.uncertainties,
vec![Uncertainty {
scope: create,
reason: UncertaintyReason::MissingGeometry
}]
);
assert_eq!(
intervals(&lane, f.owner),
vec![(0., 600., Life::Unresolved)]
);
f.scopes.remove(&f.owner);
let lane = f.lane();
assert!(lane.intervals.is_empty());
assert_eq!(lane.uncertainties[0].scope, f.owner);
}
#[test]
fn scenario_use_is_opaque_and_never_imports_callee_lifecycle_events() {
let mut f = Fixture::new(Layer::Oa);
let callee =
f.p.add_element(Kind::Scenario, Layer::Oa, "Callee", None)
.unwrap();
let remote_a =
f.p.add_element(Kind::Lifeline, Layer::Oa, "Remote A", Some(callee))
.unwrap();
let remote_b =
f.p.add_element(Kind::Lifeline, Layer::Oa, "Remote B", Some(callee))
.unwrap();
for (remote, local) in [(remote_a, f.a), (remote_b, f.b)] {
let body = f.p.elements[&local].shared().unwrap().clone();
*f.p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() = body;
}
f.occurrence(
callee,
200.,
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: Some(remote_a),
receiver: Some(remote_b),
message_kind: MessageKind::Create,
operation: None,
reply_to: None,
values: vec![],
},
);
f.occurrence(
f.owner,
100.,
OccurrenceBody::ScenarioUse {
scenario: Some(callee),
bindings: vec![
LifelineBinding {
referenced: remote_a,
local: f.a,
},
LifelineBinding {
referenced: remote_b,
local: f.b,
},
],
},
);
let lane = f.lane();
assert_eq!(intervals(&lane, f.owner), vec![(0., 600., Life::Alive)]);
assert!(lane.markers.is_empty());
assert!(lane.uncertainties.is_empty());
}
#[test]
fn projection_retains_exact_authored_source_and_does_not_enter_history() {
use crate::document::Document;
let mut f = Fixture::new(Layer::Oa);
f.message(f.owner, 100., MessageKind::Create);
f.message(f.owner, 300., MessageKind::Delete);
let mut source = source_project::format_bundle(&f.p);
let text = source.files.get_mut(&source.entry).unwrap();
*text = format!("// Retained author note — 雨\r\n{text}\n");
let document =
Document::from_bundle(source.clone(), source_project::ProjectLimits::default()).unwrap();
let accepted = document.project().clone();
let geometry = Geometry {
rows: &f.rows,
scopes: &f.scopes,
};
let result = project_lifetimes(document.project(), f.owner, &geometry);
assert_eq!(result, f.project());
assert_eq!(document.bundle(), &source);
assert_eq!(document.project(), &accepted);
assert!(!document.can_undo());
assert!(!document.can_redo());
}
+2
View File
@@ -18,6 +18,8 @@ mod context_fields;
mod data_fields;
mod history_fields;
mod port_fields;
#[cfg(test)]
mod reference_chooser;
mod view_fields;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -120,7 +120,9 @@ fn seed(schema: &'static Value, depth: usize) -> Result<Value, &'static str> {
if let Some(options) = alternatives(schema) {
if options
.iter()
.any(|s| matches_value(s, &json!("NotApplicable"), 0))
.filter_map(|s| resolve(s).ok())
.filter_map(|s| s.get("enum").and_then(Value::as_array))
.any(|values| values.contains(&json!("NotApplicable")))
{
return Ok(json!("NotApplicable"));
}
@@ -577,11 +579,11 @@ fn render<'a>(
for (index, item) in items.iter().enumerate().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
let item_path = format!("{path}/{index}");
let mut card = column![render(draft, project, item_schema, Some(item), &item_path, &format!("{} {}", title, index + 1), depth + 1)].spacing(8);
if path == FACETS {
if let Some(current) = current_observation(draft, project, &item_path) {
card = card.push(button("Use current counterpart observations")
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
}
if path == FACETS
&& let Some(current) = current_observation(draft, project, &item_path)
{
card = card.push(button("Use current counterpart observations")
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
}
card = card.push(row![
button("↑").on_press_maybe((index > 0).then(|| Message::FieldArray(path.into(), Some(index), -1))),
@@ -752,6 +752,38 @@ fn choose_history_constructor(draft: &mut Draft, path: &str, label: &str) {
draft.shape(&path, value).unwrap();
}
#[test]
fn history_optional_uuid_and_text_seed_absence_without_inventing_observations() {
for fact in ["Header/Parent", "Artifact/review/Revision"] {
let side = format!("{FACETS}/0/{fact}/oa");
let schema = resolve(schema_at(&side).unwrap()).unwrap();
assert_eq!(seed(schema, 0).unwrap(), json!("NotApplicable"));
let observed_value = alternatives(schema)
.unwrap()
.iter()
.find(|branch| branch_label(branch) == "Observed value")
.unwrap();
let value = branch_seed(observed_value).unwrap();
assert_eq!(value, json!({"Value": null}));
assert_eq!(
seed(schema_at(&format!("{side}/Value")).unwrap(), 0).unwrap(),
Value::Null,
"a plain text/UUID shape is not the NotApplicable enum constructor"
);
if fact == "Header/Parent" {
assert_eq!(
serde_json::from_value::<Observed<Option<Id>>>(value).unwrap(),
Observed::Value(None)
);
} else {
assert_eq!(
serde_json::from_value::<Observed<Option<String>>>(value).unwrap(),
Observed::Value(None)
);
}
}
}
#[test]
fn history_nested_optional_number_events_switch_absent_present_and_recover_invalid_raw() {
let (project, id) = fixture(Some(baseline(vec![])));
@@ -0,0 +1,387 @@
//! Isolated live-reference chooser experiment, declared only under cfg(test).
//! The host supplies an eligible catalog and revalidates every
//! Write intent against the current document before changing a staged scalar.
//! No core command, draft serialization, or App message belongs in this module.
use oa_core::Id;
use std::collections::BTreeSet;
#[cfg(test)]
mod tests;
pub mod widget;
pub const MAX_ROWS: usize = 6;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Context {
pub document: Id,
pub revision: u64,
pub draft: Id,
pub record: Id,
pub path: String,
pub optional: bool,
}
impl Context {
/// This is an additional historical-path guard, not live schema discovery.
/// Call only after the parent form has identified a live reference field.
pub fn live(
document: Id,
revision: u64,
draft: Id,
record: Id,
path: impl Into<String>,
optional: bool,
) -> Result<Self, &'static str> {
let path = path.into();
if !path.starts_with('/') || path.split('/').any(|part| part == "baseline") {
return Err("The chooser requires a live reference property.");
}
Ok(Self {
document,
revision,
draft,
record,
path,
optional,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
/// For a diagram placement this is the placement ID, not its element ID.
pub id: Id,
pub name: String,
pub alias: String,
pub kind: String,
pub scope: String,
pub containment: String,
}
impl Candidate {
pub fn context_label(&self) -> String {
format!("{} · {} · {}", self.scope, self.kind, self.containment)
}
pub fn detail(&self) -> String {
format!(
"{} · @{} · {} · {}",
self.name,
self.alias,
self.context_label(),
self.id
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Current {
Unassigned,
Valid(Candidate),
Incompatible(Candidate),
Missing(Id),
InvalidRaw(String),
}
impl Current {
pub fn label(&self) -> String {
match self {
Self::Unassigned => "Not assigned".into(),
Self::Valid(c) => format!("Current: {} · @{}", c.name, c.alias),
Self::Incompatible(c) => {
format!("Current choice needs review: {} · @{}", c.name, c.alias)
}
Self::Missing(id) => format!("Missing reference: {id}"),
Self::InvalidRaw(raw) => format!("Invalid reference retained: {raw}"),
}
}
}
#[derive(Debug, Clone)]
struct Searchable {
candidate: Candidate,
all: String,
alias: String,
name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stamp {
pub context: Context,
pub invocation: Id,
pub results: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Close {
Escape,
Tab { backwards: bool },
Outside,
WindowChanged,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// The mounted text input numbers emitted edits. Multiple edits in one
/// runtime batch may share the old result stamp; none may be dropped.
Query {
text: String,
serial: u64,
},
Move {
delta: i32,
rows: usize,
},
Page {
delta: i32,
rows: usize,
},
Highlight(Id),
Choose(Id),
Clear,
Close(Close),
}
impl Action {
pub fn writes_staged_value(&self) -> bool {
matches!(self, Self::Choose(_) | Self::Clear)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Event {
pub stamp: Stamp,
pub action: Action,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Ignored,
Browsed,
Closed(Close),
/// The host must still check current eligibility and scoped draft guards.
Write {
context: Context,
value: Option<Id>,
},
}
#[derive(Debug, Clone)]
pub struct State {
context: Context,
invocation: Id,
results: u64,
serial: u64,
open: bool,
candidates: Vec<Searchable>,
filtered: Vec<usize>,
query: String,
start: usize,
highlight: Option<usize>,
pub current: Current,
pub scope_label: String,
}
impl State {
pub fn open(
context: Context,
candidates: Vec<Candidate>,
current: Current,
scope_label: impl Into<String>,
) -> Result<Self, &'static str> {
// Context fields are public for host comparisons; check again in case
// a caller constructed one without the live-path constructor.
if !context.path.starts_with('/') || context.path.split('/').any(|p| p == "baseline") {
return Err("Historical values are not live reference candidates.");
}
let mut seen = BTreeSet::new();
let mut indexed = Vec::with_capacity(candidates.len());
for candidate in candidates {
if candidate.id.is_nil() || !seen.insert(candidate.id) {
return Err("Eligible references must have distinct, non-nil identities.");
}
indexed.push(Searchable {
all: candidate.detail().to_lowercase(),
alias: candidate.alias.to_lowercase(),
name: candidate.name.to_lowercase(),
candidate,
});
}
indexed.sort_by(|a, b| {
(&a.name, &a.alias, a.candidate.id).cmp(&(&b.name, &b.alias, b.candidate.id))
});
let filtered: Vec<_> = (0..indexed.len()).collect();
let highlight = (!filtered.is_empty()).then_some(0);
Ok(Self {
context,
invocation: Id::new_v4(),
results: 0,
serial: 0,
open: true,
candidates: indexed,
filtered,
query: String::new(),
start: 0,
highlight,
current,
scope_label: scope_label.into(),
})
}
pub fn stamp(&self) -> Stamp {
Stamp {
context: self.context.clone(),
invocation: self.invocation,
results: self.results,
}
}
pub fn event(&self, action: Action) -> Event {
Event {
stamp: self.stamp(),
action,
}
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn query(&self) -> &str {
&self.query
}
pub fn query_serial(&self) -> u64 {
self.serial
}
pub fn count(&self) -> usize {
self.filtered.len()
}
pub fn start(&self) -> usize {
self.start
}
pub fn optional(&self) -> bool {
self.context.optional
}
pub fn highlighted(&self) -> Option<&Candidate> {
self.highlight.and_then(|i| self.at(i))
}
fn at(&self, index: usize) -> Option<&Candidate> {
self.filtered
.get(index)
.map(|i| &self.candidates[*i].candidate)
}
pub fn visible(&self, rows: usize) -> impl Iterator<Item = &Candidate> {
self.filtered
.iter()
.skip(self.start)
.take(rows.min(MAX_ROWS))
.map(|index| &self.candidates[*index].candidate)
}
/// The host calls this before rendering on any path/document/facet/context
/// replacement. Existing events remain tied to the old invocation.
pub fn invalidate(&mut self) {
self.open = false;
}
pub fn update(&mut self, event: Event) -> Outcome {
if !self.open
|| event.stamp.context != self.context
|| event.stamp.invocation != self.invocation
{
return Outcome::Ignored;
}
// Absolute row actions require exactly the presented result set.
// Relative navigation and dismissal are processed in input order within
// this session: typing must not make a following Escape stale.
if matches!(event.action, Action::Choose(_) | Action::Highlight(_))
&& event.stamp.results != self.results
{
return Outcome::Ignored;
}
match event.action {
Action::Query { text, serial } => {
if serial <= self.serial {
return Outcome::Ignored;
}
self.serial = serial;
self.query = text;
let query = self.query.to_lowercase();
let terms: Vec<_> = query.split_whitespace().collect();
self.filtered = self
.candidates
.iter()
.enumerate()
.filter_map(|(i, c)| {
terms
.iter()
.all(|term| match term.strip_prefix('@') {
Some(alias) => c.alias.contains(alias),
None => c.all.contains(term),
})
.then_some(i)
})
.collect();
self.start = 0;
self.highlight = (!self.filtered.is_empty()).then_some(0);
}
Action::Page { delta, rows } => {
let rows = rows.clamp(1, MAX_ROWS);
if self.filtered.is_empty() {
return Outcome::Ignored;
}
let relative = self
.highlight
.unwrap_or(self.start)
.saturating_sub(self.start)
.min(rows - 1);
let last_page = (self.filtered.len() - 1) / rows * rows;
self.start = (self.start as i64 + i64::from(delta) * rows as i64)
.clamp(0, last_page as i64) as usize;
self.highlight = Some((self.start + relative).min(self.filtered.len() - 1));
}
Action::Move { delta, rows } => {
let rows = rows.clamp(1, MAX_ROWS);
if self.filtered.is_empty() {
return Outcome::Ignored;
}
let current = self.highlight.unwrap_or(self.start) as i64;
let next =
(current + i64::from(delta)).clamp(0, self.filtered.len() as i64 - 1) as usize;
self.highlight = Some(next);
if next < self.start {
self.start = next;
}
if next >= self.start + rows {
self.start = next + 1 - rows;
}
}
Action::Highlight(id) => {
let Some(index) = self
.filtered
.iter()
.position(|i| self.candidates[*i].candidate.id == id)
else {
return Outcome::Ignored;
};
if self.highlight == Some(index) {
return Outcome::Ignored;
}
self.highlight = Some(index);
}
Action::Choose(id) => {
if !self
.filtered
.iter()
.any(|i| self.candidates[*i].candidate.id == id)
{
return Outcome::Ignored;
}
self.open = false;
return Outcome::Write {
context: self.context.clone(),
value: Some(id),
};
}
Action::Clear if self.context.optional => {
self.open = false;
return Outcome::Write {
context: self.context.clone(),
value: None,
};
}
Action::Clear => return Outcome::Ignored,
Action::Close(reason) => {
self.open = false;
return Outcome::Closed(reason);
}
}
self.results = self.results.wrapping_add(1);
Outcome::Browsed
}
}
@@ -0,0 +1,803 @@
use super::*;
use iced::{
Element, Event as Input, Point, Rectangle, Size, Theme, Vector,
advanced::{Layout, Shell, layout, widget::Tree},
keyboard, mouse,
widget::{Space, mouse_area},
};
fn context(optional: bool) -> Context {
Context::live(
Id::from_u128(1),
7,
Id::from_u128(2),
Id::from_u128(3),
"/body/SequenceUse/sequence",
optional,
)
.unwrap()
}
fn candidate(index: u128) -> Candidate {
Candidate {
id: Id::from_u128(10_000 + index),
name: "Control".into(),
alias: format!("controller_{index:04}"),
kind: "Function".into(),
scope: "SA".into(),
containment: format!("System / Subsystem {} / Controller", index / 100),
}
}
fn thousand(current: Current, optional: bool) -> State {
// Reverse the fixture input to falsify accidental dependence on ingestion
// order. Expected IDs and aliases below are literal fixture identities.
State::open(
context(optional),
(0..1_000).rev().map(candidate).collect(),
current,
"SA · compatible functions",
)
.unwrap()
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Message {
Chooser(Event),
UnderPress,
UnderRelease,
}
#[derive(Default)]
struct Clipboard(Option<String>);
impl iced::advanced::Clipboard for Clipboard {
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
self.0.clone()
}
fn write(&mut self, _: iced::advanced::clipboard::Kind, text: String) {
self.0 = Some(text);
}
}
struct Mount {
tree: Option<Tree>,
size: Size,
anchor: Rectangle,
clipboard: Clipboard,
}
impl Default for Mount {
fn default() -> Self {
Self {
tree: None,
size: Size::new(1100., 700.),
anchor: Rectangle {
x: 790.,
y: 640.,
width: 278.,
height: 32.,
},
clipboard: Clipboard::default(),
}
}
}
impl Mount {
fn batch(
&mut self,
state: Option<&State>,
events: &[(Input, Option<Point>)],
) -> (Vec<Message>, Option<layout::Node>) {
let underneath: Element<'_, Message, Theme, ()> =
mouse_area(Space::new().width(iced::Fill).height(iced::Fill))
.on_press(Message::UnderPress)
.on_release(Message::UnderRelease)
.into();
let mut view: Element<'_, Message, Theme, ()> =
widget::Popup::new(underneath, state, self.anchor, Message::Chooser).into();
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
view.as_widget().diff(tree);
let node =
view.as_widget_mut()
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
let viewport = Rectangle::with_size(self.size);
let overlay_node = view
.as_widget_mut()
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
let mut messages = Vec::new();
for (event, point) in events {
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
let mut shell = Shell::new(&mut messages);
if let Some(overlay_node) = &overlay_node {
// Reconstruct the overlay between layout and every input, as
// Iced does. Geometry must live in the measured layout tree.
let mut overlay = view
.as_widget_mut()
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
.unwrap();
overlay.as_overlay_mut().update(
event,
Layout::new(overlay_node),
cursor,
&(),
&mut self.clipboard,
&mut shell,
);
overlay.as_overlay().draw(
&mut (),
&crate::style::theme(),
&iced::advanced::renderer::Style {
text_color: iced::Color::BLACK,
},
Layout::new(overlay_node),
cursor,
);
if matches!(
event,
Input::Mouse(_) | Input::Keyboard(_) | Input::Touch(_) | Input::InputMethod(_)
) {
assert!(shell.is_event_captured(), "An open chooser owns input");
}
} else {
view.as_widget_mut().update(
tree,
event,
Layout::new(&node),
cursor,
&(),
&mut self.clipboard,
&mut shell,
&viewport,
);
}
}
(messages, overlay_node)
}
fn send(&mut self, state: &mut State, event: Input) -> Vec<Outcome> {
let (messages, _) = self.batch(Some(state), &[(event, None)]);
messages
.into_iter()
.map(|message| match message {
Message::Chooser(event) => state.update(event),
_ => panic!("Popup leaked input to its parent"),
})
.collect()
}
fn type_text(&mut self, state: &mut State, text: &str) -> Vec<Outcome> {
let (messages, _) = self.batch(Some(state), &typing(text));
messages
.into_iter()
.map(|message| match message {
Message::Chooser(event) => state.update(event),
_ => panic!("Typing leaked input to its parent"),
})
.collect()
}
fn geometry(&mut self, state: &State) -> widget::Geometry {
let (_, node) = self.batch(Some(state), &[]);
widget::Geometry::from_bounds(node.unwrap().children()[0].bounds())
}
fn click(&mut self, state: &State, point: Point) -> Vec<Message> {
self.batch(
Some(state),
&[
(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
),
(
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point),
),
],
)
.0
}
}
fn named(key: keyboard::key::Named) -> Input {
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key),
modified_key: keyboard::Key::Named(key),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
// Iced 0.14's physical-key path intentionally inserts one scalar from text.
// Whole-string typing is several key events; paste and IME commit are separate
// input paths and are exercised below rather than faked as one physical key.
fn typed(character: char) -> Input {
let text = character.to_string();
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(text.clone().into()),
modified_key: keyboard::Key::Character(text.clone().into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some(text.into()),
repeat: false,
})
}
fn typing(text: &str) -> Vec<(Input, Option<Point>)> {
text.chars()
.map(|character| (typed(character), None))
.collect()
}
fn query(state: &mut State, value: &str) {
let event = state.event(Action::Query {
text: value.into(),
serial: state.query_serial() + 1,
});
assert_eq!(state.update(event), Outcome::Browsed);
}
#[test]
fn catalog_search_disambiguates_repeated_names_containment_alias_and_exact_placement_identity() {
let mut state = thousand(Current::Unassigned, false);
assert_eq!(
state.visible(6).map(|c| c.id).collect::<Vec<_>>(),
(10_000..10_006).map(Id::from_u128).collect::<Vec<_>>()
);
query(&mut state, "control subsystem 6 @controller_0617");
assert_eq!(state.count(), 1);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_617));
query(&mut state, "OA");
assert_eq!(state.count(), 0);
query(&mut state, &Id::from_u128(10_617).to_string());
assert_eq!(state.count(), 1);
assert_eq!(state.highlighted().unwrap().alias, "controller_0617");
let placement = Candidate {
id: Id::from_u128(20_001),
name: "Control".into(),
alias: "control_placement".into(),
kind: "Placement".into(),
scope: "SA".into(),
containment: "Architecture / Main diagram / Control".into(),
};
let mut state = State::open(
context(false),
vec![placement],
Current::Unassigned,
"SA · placements",
)
.unwrap();
query(&mut state, "placement main");
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(20_001));
let event = state.event(Action::Choose(Id::from_u128(20_000)));
assert_eq!(
state.update(event),
Outcome::Ignored,
"Semantic ID is not the placement identity"
);
assert!(
State::open(
context(false),
vec![candidate(1), candidate(1)],
Current::Unassigned,
"SA"
)
.is_err()
);
}
#[test]
fn mounted_overlay_clamps_at_three_field_positions_and_keeps_results_and_query_bounded() {
let state = thousand(Current::InvalidRaw("not a uuid".into()), true);
for size in [
Size::new(1100., 700.),
Size::new(1600., 1000.),
Size::new(320., 320.),
] {
for y in [5., size.height / 2., size.height - 34.] {
let mut mount = Mount {
size,
anchor: Rectangle {
x: size.width - 300.,
y,
width: 278.,
height: 32.,
},
..Default::default()
};
let (_, node) = mount.batch(Some(&state), &[]);
let node = node.unwrap();
let bounds = node.children()[0].bounds();
let geometry = widget::Geometry::from_bounds(bounds);
assert!(bounds.x >= 8. && bounds.y >= 8.);
assert!(bounds.x + bounds.width <= size.width - 8.);
assert!(bounds.y + bounds.height <= size.height - 8.);
assert!(bounds.width <= 420. && bounds.height <= 360.);
assert!(geometry.rows > 0 && geometry.rows <= 6);
assert_eq!(geometry.rows, if size.height == 320. { 4 } else { 6 });
let search = node.children()[1].bounds();
assert!(
bounds.contains(search.position())
&& bounds.contains(Point::new(
search.x + search.width,
search.y + search.height
))
);
assert!(geometry.row(geometry.rows - 1).y + 34. <= geometry.detail.y);
assert_eq!(state.visible(geometry.rows).count(), geometry.rows);
}
}
}
#[test]
fn mounted_input_preserves_rapid_same_batch_typing_and_cursor_across_controlled_rebuilds() {
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
let (messages, _) = mount.batch(Some(&state), &[(typed('a'), None), (typed('b'), None)]);
assert_eq!(
messages.len(),
2,
"Actual focused input must publish each edit"
);
let mut queries = Vec::new();
for message in messages {
let Message::Chooser(event) = message else {
panic!("Unexpected parent event")
};
let Action::Query { text, .. } = &event.action else {
panic!("Typing must not choose a reference")
};
queries.push(text.clone());
assert_eq!(state.update(event), Outcome::Browsed);
}
assert_eq!(queries, ["a", "ab"]);
assert_eq!(state.query(), "ab");
assert!(
mount
.send(&mut state, named(keyboard::key::Named::Home))
.is_empty()
);
assert_eq!(mount.send(&mut state, typed('z')), [Outcome::Browsed]);
assert_eq!(
state.query(),
"zab",
"Rebuilding the controlled widget must not refocus at the end"
);
assert_eq!(state.current, Current::Unassigned);
}
#[test]
fn mounted_keyboard_pages_and_selects_only_the_highlighted_visible_candidate_once() {
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
assert_eq!(
mount.send(&mut state, named(keyboard::key::Named::PageDown)),
[Outcome::Browsed]
);
assert_eq!(state.start(), 6);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_006));
assert_eq!(
mount.send(&mut state, named(keyboard::key::Named::ArrowDown)),
[Outcome::Browsed]
);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_007));
let (messages, _) = mount.batch(
Some(&state),
&[
(named(keyboard::key::Named::Enter), None),
(named(keyboard::key::Named::Enter), None),
],
);
assert_eq!(messages.len(), 1);
let Message::Chooser(event) = messages.into_iter().next().unwrap() else {
panic!()
};
assert!(event.action.writes_staged_value());
assert_eq!(
state.update(event),
Outcome::Write {
context: context(false),
value: Some(Id::from_u128(10_007))
}
);
assert!(!state.is_open());
}
#[test]
fn mounted_filtered_row_click_uses_the_displayed_identity_and_scoped_write_intent() {
let mut state = thousand(Current::Missing(Id::from_u128(99)), false);
let mut mount = Mount::default();
let query = "@controller_0731";
assert_eq!(
mount.type_text(&mut state, query),
vec![Outcome::Browsed; query.chars().count()]
);
assert_eq!(state.query(), query);
assert_eq!(state.count(), 1);
let point = mount.geometry(&state).row(0).center();
let messages = mount.click(&state, point);
assert_eq!(messages.len(), 1);
let Message::Chooser(event) = messages[0].clone() else {
panic!()
};
assert_eq!(
state.update(event),
Outcome::Write {
context: context(false),
value: Some(Id::from_u128(10_731))
}
);
assert_eq!(state.current, Current::Missing(Id::from_u128(99)));
}
#[test]
fn mounted_query_change_rejects_old_release_and_old_result_events_without_locking_popup() {
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
let point = mount.geometry(&state).row(4).center();
let captured = mount.click(&state, point);
let Message::Chooser(old_choice) = captured[0].clone() else {
panic!()
};
query(&mut state, "@controller_0700");
assert_eq!(state.update(old_choice), Outcome::Ignored);
// The rejected old Choose had marked the widget closing; a changed result
// context must unlock it so an explicit current click can complete.
let point = mount.geometry(&state).row(0).center();
let messages = mount.click(&state, point);
assert_eq!(messages.len(), 1);
let Message::Chooser(event) = messages[0].clone() else {
panic!()
};
assert_eq!(
state.update(event),
Outcome::Write {
context: context(false),
value: Some(Id::from_u128(10_700))
}
);
let mut state = thousand(Current::Unassigned, false);
let point = mount.geometry(&state).row(0).center();
assert!(
mount
.batch(
Some(&state),
&[(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point)
)]
)
.0
.is_empty()
);
query(&mut state, "@controller_0319");
assert!(
mount
.batch(
Some(&state),
&[(
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point)
)]
)
.0
.is_empty()
);
assert!(state.is_open());
}
#[test]
fn captured_choice_cannot_cross_document_revision_draft_record_or_array_path() {
let source = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
let point = mount.geometry(&source).row(0).center();
let Message::Chooser(event) = mount.click(&source, point).remove(0) else {
panic!()
};
for changed in 0..6 {
let mut destination = context(false);
match changed {
0 => destination.document = Id::from_u128(999),
1 => destination.revision += 1,
2 => destination.draft = Id::from_u128(999),
3 => destination.record = Id::from_u128(999),
4 => destination.path = "/body/SomeList/members/1/scenario".into(),
_ => {} // A new invocation even with every semantic ID unchanged.
}
let mut state = State::open(
destination,
vec![candidate(0)],
Current::InvalidRaw("unfinished".into()),
"SA",
)
.unwrap();
assert_eq!(state.update(event.clone()), Outcome::Ignored);
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
assert!(state.is_open());
}
let mut invalidated = source.clone();
invalidated.invalidate();
assert_eq!(invalidated.update(event), Outcome::Ignored);
}
#[test]
fn empty_search_escape_tab_and_optional_clear_do_not_silently_choose_nil() {
for current in [
Current::Missing(Id::from_u128(999)),
Current::InvalidRaw("@incomplete/".into()),
Current::Incompatible(candidate(999)),
] {
let mut state = thousand(current.clone(), false);
let mut mount = Mount::default();
let query = "no match exists";
assert_eq!(
mount.type_text(&mut state, query),
vec![Outcome::Browsed; query.chars().count()]
);
assert_eq!(state.query(), query);
assert_eq!(
state.count(),
0,
"Verify actual input reached the empty-result state before Enter"
);
assert!(
mount
.send(&mut state, named(keyboard::key::Named::Enter))
.is_empty()
);
assert!(state.is_open());
assert_eq!(state.current, current);
assert_eq!(
mount.send(&mut state, named(keyboard::key::Named::Escape)),
[Outcome::Closed(Close::Escape)]
);
assert_eq!(state.current, current);
}
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
assert_eq!(
mount.send(&mut state, named(keyboard::key::Named::Tab)),
[Outcome::Closed(Close::Tab { backwards: false })]
);
let mut state = thousand(Current::InvalidRaw("?".into()), true);
let point = mount.geometry(&state).clear.center();
let Message::Chooser(event) = mount.click(&state, point).remove(0) else {
panic!()
};
assert_eq!(
state.update(event),
Outcome::Write {
context: context(true),
value: None
}
);
let mut required = thousand(Current::Unassigned, false);
let clear = required.event(Action::Clear);
assert_eq!(required.update(clear), Outcome::Ignored);
}
#[test]
fn dismissal_swallows_matching_release_across_closed_rebuild_but_next_click_reaches_parent() {
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
let outside = Point::new(20., 20.);
let (messages, _) = mount.batch(
Some(&state),
&[(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(outside),
)],
);
let [Message::Chooser(event)] = messages.as_slice() else {
panic!("Expected only popup dismissal")
};
assert_eq!(state.update(event.clone()), Outcome::Closed(Close::Outside));
let release = Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
assert!(
mount
.batch(None, &[(release.clone(), Some(outside))])
.0
.is_empty()
);
assert_eq!(
mount
.batch(
None,
&[
(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(outside)
),
(release, Some(outside)),
]
)
.0,
[Message::UnderPress, Message::UnderRelease]
);
}
#[test]
fn historical_paths_never_open_and_only_explicit_choice_or_clear_is_a_write() {
for path in [
"/baseline",
"/baseline/oa/value",
"/baseline/sa/value/TypeRef/Named",
] {
assert!(
Context::live(
Id::from_u128(1),
0,
Id::from_u128(2),
Id::from_u128(3),
path,
true
)
.is_err()
);
let mut forged = context(true);
forged.path = path.into();
assert!(State::open(forged, vec![candidate(0)], Current::Unassigned, "History").is_err());
}
for action in [
Action::Query {
text: "Control".into(),
serial: 1,
},
Action::Highlight(Id::from_u128(10_000)),
Action::Move { delta: 1, rows: 6 },
Action::Page { delta: 1, rows: 6 },
Action::Close(Close::Escape),
] {
assert!(!action.writes_staged_value());
}
assert!(Action::Choose(Id::from_u128(10_000)).writes_staged_value());
assert!(Action::Clear.writes_staged_value());
}
#[test]
fn out_of_order_query_serials_cannot_restore_obsolete_results() {
let mut state = thousand(Current::Unassigned, false);
let old = state.event(Action::Query {
text: "@controller_0001".into(),
serial: 1,
});
let new = state.event(Action::Query {
text: "@controller_0999".into(),
serial: 2,
});
assert_eq!(state.update(new), Outcome::Browsed);
assert_eq!(state.update(old), Outcome::Ignored);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_999));
}
#[test]
fn mounted_query_then_escape_in_one_batch_closes_and_relative_keys_keep_input_order() {
let mut state = thousand(Current::InvalidRaw("unfinished".into()), false);
let mut mount = Mount::default();
let query = "@controller_0720";
let mut inputs = typing(query);
inputs.push((named(keyboard::key::Named::Escape), None));
let (messages, _) = mount.batch(Some(&state), &inputs);
let outcomes: Vec<_> = messages
.into_iter()
.map(|message| {
let Message::Chooser(event) = message else {
panic!("Input escaped the popup")
};
state.update(event)
})
.collect();
let mut expected = vec![Outcome::Browsed; query.chars().count()];
expected.push(Outcome::Closed(Close::Escape));
assert_eq!(outcomes, expected);
assert_eq!(state.query(), "@controller_0720");
assert_eq!(state.current, Current::InvalidRaw("unfinished".into()));
let mut state = thousand(Current::Unassigned, false);
let old_absolute_row = state.event(Action::Choose(Id::from_u128(10_000)));
let (messages, _) = mount.batch(
Some(&state),
&[
(named(keyboard::key::Named::PageDown), None),
(named(keyboard::key::Named::PageDown), None),
(named(keyboard::key::Named::ArrowUp), None),
],
);
assert_eq!(messages.len(), 3);
for message in messages {
let Message::Chooser(event) = message else {
panic!("Input escaped the popup")
};
assert_eq!(state.update(event), Outcome::Browsed);
}
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_011));
assert_eq!(state.start(), 11);
assert_eq!(state.update(old_absolute_row), Outcome::Ignored);
assert!(state.is_open());
}
#[test]
fn mounted_clipboard_paste_and_ime_commit_preserve_whole_strings_without_selecting() {
let mut state = thousand(Current::Unassigned, false);
let mut mount = Mount::default();
mount.clipboard.0 = Some("@controller_0472".into());
let paste = Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character("v".into()),
modified_key: keyboard::Key::Character("v".into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyV),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::COMMAND,
text: None,
repeat: false,
});
let (messages, _) = mount.batch(
Some(&state),
&[
(
Input::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::COMMAND,
)),
None,
),
(paste, None),
(
Input::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::empty(),
)),
None,
),
],
);
let [Message::Chooser(event)] = messages.as_slice() else {
panic!("Paste should emit exactly one whole-string query")
};
assert_eq!(state.update(event.clone()), Outcome::Browsed);
assert_eq!(state.query(), "@controller_0472");
assert_eq!(state.count(), 1);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_472));
assert_eq!(state.current, Current::Unassigned);
assert!(state.is_open());
let unicode_name = "Режим Δ負荷";
let unicode = Candidate {
id: Id::from_u128(30_111),
name: unicode_name.into(),
alias: "mode_unicode".into(),
kind: "Function".into(),
scope: "SA".into(),
containment: "Система / 控制".into(),
};
let mut state = State::open(
context(false),
vec![unicode],
Current::InvalidRaw("retained".into()),
"SA",
)
.unwrap();
use iced::advanced::input_method::Event as Ime;
let preedit = [
(Input::InputMethod(Ime::Opened), None),
(
Input::InputMethod(Ime::Preedit(unicode_name.into(), None)),
None,
),
];
assert!(
mount.batch(Some(&state), &preedit).0.is_empty(),
"Composition is not a committed query"
);
assert_eq!(state.query(), "");
let (messages, _) = mount.batch(
Some(&state),
&[
(Input::InputMethod(Ime::Preedit(String::new(), None)), None),
(Input::InputMethod(Ime::Commit(unicode_name.into())), None),
(Input::InputMethod(Ime::Closed), None),
],
);
let [Message::Chooser(event)] = messages.as_slice() else {
panic!("IME commit should emit one complete query")
};
assert_eq!(state.update(event.clone()), Outcome::Browsed);
assert_eq!(state.query(), unicode_name);
assert_eq!(state.count(), 1);
assert_eq!(state.highlighted().unwrap().id, Id::from_u128(30_111));
assert_eq!(state.current, Current::InvalidRaw("retained".into()));
assert!(state.is_open());
}
@@ -0,0 +1,956 @@
//! Root overlay adapter. Keep the wrapper mounted while closed so the release
//! from a dismissal gesture cannot reach the underlying content. Parent modal
//! ownership is supplied by the host (pass no chooser while a modal is active).
use super::{Action, Close, Event as ChoiceEvent, MAX_ROWS, Stamp, State as Chooser};
use crate::style;
use iced::{
Border, Color, Element, Event, Font, Length, Point, Rectangle, Size, Theme, Vector,
advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
text::{self, Paragraph as _},
widget::{Operation, Tree, tree},
},
alignment, keyboard, mouse, touch,
widget::text_input,
window,
};
use std::{cell::Cell, rc::Rc};
const ROW: f32 = 34.;
const MARGIN: f32 = 8.;
#[derive(Debug, Clone, Copy)]
pub(super) struct Geometry {
pub popup: Rectangle,
pub search: Rectangle,
pub current: Rectangle,
pub detail: Rectangle,
pub close: Rectangle,
pub previous: Rectangle,
pub next: Rectangle,
pub clear: Rectangle,
pub rows: usize,
}
impl Geometry {
fn new(anchor: Rectangle, viewport: Size) -> Self {
let width = anchor
.width
.clamp(278., 420.)
.min((viewport.width - 2. * MARGIN).max(0.));
let height = 360f32.min((viewport.height - 2. * MARGIN).max(0.));
let x = anchor.x.clamp(
MARGIN.min(viewport.width / 2.),
(viewport.width - MARGIN - width).max(MARGIN.min(viewport.width / 2.)),
);
let below = anchor.y + anchor.height + 4.;
let preferred = if below + height + MARGIN <= viewport.height {
below
} else {
anchor.y - height - 4.
};
let y = preferred.clamp(
MARGIN.min(viewport.height / 2.),
(viewport.height - MARGIN - height).max(MARGIN.min(viewport.height / 2.)),
);
Self::from_bounds(Rectangle {
x,
y,
width,
height,
})
}
pub(super) fn from_bounds(popup: Rectangle) -> Self {
let inner = (popup.width - 16.).max(0.);
let rect = |left, top, width, height| Rectangle {
x: popup.x + left,
y: popup.y + top,
width,
height,
};
let footer = (popup.height - 32.).max(0.);
let detail_height = if popup.height >= 180. { 32. } else { 0. };
let detail_top = (footer - detail_height - 4.).max(84.);
let rows = (((detail_top - 88.).max(0.) / ROW) as usize).min(MAX_ROWS);
Self {
popup,
search: rect(8., 28., inner, 30.),
current: rect(8., 62., inner, 18.),
detail: rect(8., detail_top, inner, detail_height),
close: rect((popup.width - 58.).max(0.), 4., 50f32.min(popup.width), 22.),
previous: rect(8., footer, 28., 24.),
next: rect(40., footer, 28., 24.),
clear: rect((popup.width - 84.).max(0.), footer, 76., 24.),
rows,
}
}
pub fn row(&self, index: usize) -> Rectangle {
Rectangle {
x: self.popup.x + 8.,
y: self.popup.y + 84. + index as f32 * ROW,
width: (self.popup.width - 16.).max(0.),
height: ROW,
}
}
fn hit(&self, chooser: &Chooser, point: Point) -> Option<Action> {
if self.close.contains(point) {
return Some(Action::Close(Close::Escape));
}
if self.previous.contains(point) && chooser.start() > 0 {
return Some(Action::Page {
delta: -1,
rows: self.rows,
});
}
if self.next.contains(point) && chooser.start() + self.rows < chooser.count() {
return Some(Action::Page {
delta: 1,
rows: self.rows,
});
}
if self.clear.contains(point) && chooser.optional() {
return Some(Action::Clear);
}
chooser
.visible(self.rows)
.enumerate()
.find_map(|(i, c)| self.row(i).contains(point).then_some(Action::Choose(c.id)))
}
}
#[derive(Default)]
struct Interaction {
stamp: Option<Stamp>,
serial: u64,
pressed: Option<(ChoiceEvent, Pointer)>,
closing: bool,
swallow_mouse: Option<mouse::Button>,
swallow_touch: Option<touch::Finger>,
held_mouse: Option<mouse::Button>,
held_touch: Option<touch::Finger>,
detail_scroll: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pointer {
Mouse,
Touch(touch::Finger),
}
impl Interaction {
fn swallow(&mut self, event: &Event) -> bool {
match event {
Event::Mouse(mouse::Event::ButtonReleased(button))
if self.swallow_mouse == Some(*button) =>
{
self.swallow_mouse = None;
true
}
Event::Touch(
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
) if self.swallow_touch == Some(*id) => {
self.swallow_touch = None;
true
}
_ => false,
}
}
}
pub struct Popup<'a, Message, R = iced::Renderer>
where
R: text::Renderer<Font = Font>,
{
content: Element<'a, Message, Theme, R>,
input: Element<'a, String, Theme, R>,
chooser: Option<&'a Chooser>,
anchor: Rectangle,
on_event: Rc<dyn Fn(ChoiceEvent) -> Message + 'a>,
}
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> Popup<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
chooser: Option<&'a Chooser>,
anchor: Rectangle,
on_event: impl Fn(ChoiceEvent) -> Message + 'a,
) -> Self {
let chooser = chooser.filter(|state| state.is_open());
let input = text_input(
"Name, @alias, kind or containment",
chooser.map_or("", Chooser::query),
)
.on_input(std::convert::identity)
.size(12)
.padding(6)
.width(Length::Fill)
.into();
Self {
content: content.into(),
input,
chooser,
anchor,
on_event: Rc::new(on_event),
}
}
fn reconcile(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Interaction>();
let stamp = self.chooser.map(Chooser::stamp);
if state.stamp.as_ref().map(|s| s.invocation) != stamp.as_ref().map(|s| s.invocation) {
let input = tree.children[1]
.state
.downcast_mut::<text_input::State<R::Paragraph>>();
if stamp.is_some() {
input.focus();
} else {
input.unfocus();
}
state.closing = false;
state.serial = self.chooser.map_or(0, Chooser::query_serial);
}
if state.stamp != stamp {
state.pressed = None;
// A queued Choose can be rejected after a preceding query changed
// the result generation. Reconciliation must unlock this view.
state.closing = false;
state.detail_scroll = 0.;
}
state.stamp = stamp;
}
}
impl<Message: Clone, R: text::Renderer<Font = Font>> Widget<Message, Theme, R>
for Popup<'_, Message, R>
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Interaction>()
}
fn state(&self) -> tree::State {
tree::State::new(Interaction::default())
}
fn children(&self) -> Vec<Tree> {
vec![
Tree::new(self.content.as_widget()),
Tree::new(self.input.as_widget()),
]
}
fn diff(&self, tree: &mut Tree) {
tree.children[0].diff(self.content.as_widget());
tree.children[1].diff(self.input.as_widget());
self.reconcile(tree);
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.reconcile(tree);
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<Interaction>();
if state.swallow(event) || self.chooser.is_some() && is_input(event) {
shell.capture_event();
return;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
if self.chooser.is_some() {
mouse::Cursor::Unavailable
} else {
cursor
},
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
if self.chooser.is_some() {
mouse::Interaction::None
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
if let Some(chooser) = self.chooser {
Some(overlay::Element::new(Box::new(ChooserOverlay {
chooser,
anchor: self.anchor,
state: tree.state.downcast_mut(),
input: &mut self.input,
input_tree: &mut tree.children[1],
on_event: &*self.on_event,
})))
} else {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
}
impl<'a, Message: Clone + 'a, R: text::Renderer<Font = Font> + 'a> From<Popup<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(popup: Popup<'a, Message, R>) -> Self {
Element::new(popup)
}
}
struct ChooserOverlay<'b, 'a, Message, R: text::Renderer<Font = Font>> {
chooser: &'a Chooser,
anchor: Rectangle,
state: &'b mut Interaction,
input: &'b mut Element<'a, String, Theme, R>,
input_tree: &'b mut Tree,
on_event: &'b dyn Fn(ChoiceEvent) -> Message,
}
impl<Message, R: text::Renderer<Font = Font>> ChooserOverlay<'_, '_, Message, R> {
fn geometry(&self, layout: Layout<'_>) -> Geometry {
Geometry::from_bounds(
layout
.children()
.next()
.expect("measured chooser bounds")
.bounds(),
)
}
fn publish(&mut self, action: Action, shell: &mut Shell<'_, Message>) {
if self.state.closing {
return;
}
if action.writes_staged_value() || matches!(action, Action::Close(_)) {
self.state.closing = true;
self.state.swallow_mouse = self.state.held_mouse.or(self.state.swallow_mouse);
self.state.swallow_touch = self.state.held_touch.or(self.state.swallow_touch);
}
shell.publish((self.on_event)(self.chooser.event(action)));
}
fn press(
&mut self,
point: Point,
pointer: Pointer,
geometry: Geometry,
shell: &mut Shell<'_, Message>,
) {
if !geometry.popup.contains(point) {
self.publish(Action::Close(Close::Outside), shell);
} else {
self.state.pressed = geometry
.hit(self.chooser, point)
.map(|action| (self.chooser.event(action), pointer));
}
}
fn release(
&mut self,
point: Point,
pointer: Pointer,
geometry: Geometry,
shell: &mut Shell<'_, Message>,
) {
if let Some((pressed, origin)) = self.state.pressed.take()
&& origin == pointer
&& pressed.stamp == self.chooser.stamp()
&& geometry.hit(self.chooser, point).as_ref() == Some(&pressed.action)
{
self.publish(pressed.action, shell);
}
}
}
impl<Message: Clone, R: text::Renderer<Font = Font>> overlay::Overlay<Message, Theme, R>
for ChooserOverlay<'_, '_, Message, R>
{
fn layout(&mut self, renderer: &R, bounds: Size) -> layout::Node {
let geometry = Geometry::new(self.anchor, bounds);
let input = self
.input
.as_widget_mut()
.layout(
self.input_tree,
renderer,
&layout::Limits::new(Size::ZERO, geometry.search.size()),
)
.move_to(geometry.search.position());
layout::Node::with_children(
bounds,
vec![
layout::Node::new(geometry.popup.size()).move_to(geometry.popup.position()),
input,
],
)
}
fn operate(&mut self, layout: Layout<'_>, renderer: &R, operation: &mut dyn Operation) {
self.input.as_widget_mut().operate(
self.input_tree,
layout.children().nth(1).unwrap(),
renderer,
operation,
);
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
let geometry = self.geometry(layout);
match event {
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
self.state.held_mouse = Some(*button)
}
Event::Mouse(mouse::Event::ButtonReleased(button))
if self.state.held_mouse == Some(*button) =>
{
self.state.held_mouse = None
}
Event::Touch(touch::Event::FingerPressed { id, .. }) => {
self.state.held_touch = Some(*id)
}
Event::Touch(
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
) if self.state.held_touch == Some(*id) => self.state.held_touch = None,
_ => {}
}
if self.state.swallow(event) {
shell.capture_event();
return;
}
if self.state.closing {
if is_input(event) {
shell.capture_event();
}
return;
}
let mut handled = false;
match event {
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
use keyboard::key::Named;
let action = match key {
keyboard::Key::Named(Named::Escape) => Some(Action::Close(Close::Escape)),
keyboard::Key::Named(Named::Tab) => Some(Action::Close(Close::Tab {
backwards: modifiers.shift(),
})),
keyboard::Key::Named(Named::ArrowDown) => Some(Action::Move {
delta: 1,
rows: geometry.rows,
}),
keyboard::Key::Named(Named::ArrowUp) => Some(Action::Move {
delta: -1,
rows: geometry.rows,
}),
keyboard::Key::Named(Named::PageDown) => Some(Action::Page {
delta: 1,
rows: geometry.rows,
}),
keyboard::Key::Named(Named::PageUp) => Some(Action::Page {
delta: -1,
rows: geometry.rows,
}),
keyboard::Key::Named(Named::Enter) => {
handled = true;
self.chooser
.highlighted()
.filter(|c| self.chooser.visible(geometry.rows).any(|v| v.id == c.id))
.map(|c| Action::Choose(c.id))
}
keyboard::Key::Character(c)
if modifiers.control()
&& modifiers.shift()
&& c.eq_ignore_ascii_case("c") =>
{
clipboard.write(
iced::advanced::clipboard::Kind::Standard,
self.chooser
.highlighted()
.map_or_else(|| self.chooser.current.label(), |c| c.detail()),
);
handled = true;
None
}
_ => None,
};
if let Some(action) = action {
self.publish(action, shell);
handled = true;
}
}
Event::Window(window::Event::Resized(_) | window::Event::Unfocused) => {
self.publish(Action::Close(Close::WindowChanged), shell);
handled = true;
}
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
if let Some(point) = cursor.position() {
if !geometry.popup.contains(point) {
self.state.swallow_mouse = Some(*button);
self.press(point, Pointer::Mouse, geometry, shell);
handled = true;
} else if *button == mouse::Button::Left && !geometry.search.contains(point) {
self.press(point, Pointer::Mouse, geometry, shell);
handled = true;
}
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
if self.state.pressed.is_some() =>
{
if let Some(point) = cursor.position() {
self.release(point, Pointer::Mouse, geometry, shell);
} else {
self.state.pressed = None;
}
handled = true;
}
Event::Mouse(mouse::Event::CursorMoved { position })
if self.state.pressed.is_none() =>
{
if let Some(Action::Choose(id)) = geometry.hit(self.chooser, *position)
&& self
.chooser
.highlighted()
.is_none_or(|candidate| candidate.id != id)
{
self.publish(Action::Highlight(id), shell);
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
let (x, y) = match delta {
mouse::ScrollDelta::Lines { x, y } => (*x * 24., *y * 24.),
mouse::ScrollDelta::Pixels { x, y } => (*x, *y),
};
self.state.pressed = None;
if cursor
.position()
.is_some_and(|p| geometry.detail.contains(p))
{
let detail = self
.chooser
.highlighted()
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
let max = (measure::<R>(&detail, 11., renderer.default_font())
- geometry.detail.width)
.max(0.);
self.state.detail_scroll =
(self.state.detail_scroll - if x != 0. { x } else { y }).clamp(0., max);
shell.request_redraw();
} else if y != 0. {
self.publish(
Action::Page {
delta: if y < 0. { 1 } else { -1 },
rows: geometry.rows,
},
shell,
);
}
handled = true;
}
Event::Touch(touch::Event::FingerPressed { id, position })
if !geometry.search.contains(*position) =>
{
if !geometry.popup.contains(*position) {
self.state.swallow_touch = Some(*id);
}
self.press(*position, Pointer::Touch(*id), geometry, shell);
handled = true;
}
Event::Touch(touch::Event::FingerLifted { id, position })
if self.state.pressed.is_some() =>
{
self.release(*position, Pointer::Touch(*id), geometry, shell);
handled = true;
}
Event::Touch(touch::Event::FingerLost { .. } | touch::Event::FingerMoved { .. }) => {
self.state.pressed = None;
}
_ => {}
}
if !handled {
let mut messages = Vec::new();
let mut input_shell = Shell::new(&mut messages);
self.input.as_widget_mut().update(
self.input_tree,
event,
layout.children().nth(1).unwrap(),
cursor,
renderer,
clipboard,
&mut input_shell,
&geometry.popup,
);
let serial = Cell::new(self.state.serial);
shell.merge(input_shell, |text| {
serial.set(serial.get().saturating_add(1));
(self.on_event)(self.chooser.event(Action::Query {
text,
serial: serial.get(),
}))
});
self.state.serial = serial.get();
}
if is_input(event) {
shell.capture_event();
}
}
fn draw(
&self,
renderer: &mut R,
theme: &Theme,
renderer_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
let geometry = self.geometry(layout);
renderer.fill_quad(
renderer::Quad {
bounds: geometry.popup,
border: Border {
color: style::LINE,
width: 1.,
radius: 8.into(),
},
..Default::default()
},
Color::WHITE,
);
renderer.with_layer(geometry.popup, |renderer| {
label(
renderer,
&self.chooser.scope_label,
11.,
style::MUTED,
Rectangle {
x: geometry.popup.x + 8.,
y: geometry.popup.y + 4.,
width: (geometry.popup.width - 78.).max(0.),
height: 22.,
},
);
label(renderer, "Close", 11., style::TEAL, geometry.close);
label(
renderer,
&self.chooser.current.label(),
11.,
style::MUTED,
geometry.current,
);
for (i, candidate) in self.chooser.visible(geometry.rows).enumerate() {
let row = geometry.row(i);
if self
.chooser
.highlighted()
.is_some_and(|c| c.id == candidate.id)
{
renderer.fill_quad(
renderer::Quad {
bounds: row,
..Default::default()
},
Color::from_rgb8(218, 238, 235),
);
}
let alias_width = (row.width * 0.42).min(
measure::<R>(
&format!("@{}", candidate.alias),
11.,
renderer.default_font(),
) + 4.,
);
label(
renderer,
&candidate.name,
12.,
style::INK,
Rectangle {
width: (row.width - alias_width - 6.).max(0.),
height: 17.,
..row
},
);
label(
renderer,
&format!("@{}", candidate.alias),
11.,
style::INK,
Rectangle {
x: row.x + row.width - alias_width,
width: alias_width,
height: 17.,
..row
},
);
label(
renderer,
&candidate.context_label(),
10.,
style::MUTED,
Rectangle {
y: row.y + 17.,
height: 17.,
..row
},
);
}
if self.chooser.count() == 0 {
label(
renderer,
"No compatible matches. Clear the query.",
11.,
style::MUTED,
geometry.row(0),
);
}
let detail = self
.chooser
.highlighted()
.map_or_else(|| self.chooser.current.label(), |c| c.detail());
renderer.with_layer(geometry.detail, |renderer| {
raw_label(
renderer,
detail,
11.,
style::MUTED,
Rectangle {
x: geometry.detail.x - self.state.detail_scroll,
width: geometry.detail.width + self.state.detail_scroll,
height: 16.,
..geometry.detail
},
);
label(
renderer,
"Scroll details · Ctrl+Shift+C copies",
10.,
style::MUTED,
Rectangle {
y: geometry.detail.y + 16.,
height: 16.,
..geometry.detail
},
);
});
label(
renderer,
"‹",
15.,
if self.chooser.start() > 0 {
style::TEAL
} else {
style::MUTED
},
geometry.previous,
);
label(
renderer,
"›",
15.,
if self.chooser.start() + geometry.rows < self.chooser.count() {
style::TEAL
} else {
style::MUTED
},
geometry.next,
);
let count = if self.chooser.count() == 0 {
"0 results".into()
} else {
format!(
"{}–{} / {}",
self.chooser.start() + 1,
(self.chooser.start() + geometry.rows).min(self.chooser.count()),
self.chooser.count()
)
};
label(
renderer,
&count,
11.,
style::MUTED,
Rectangle {
x: geometry.next.x + 30.,
width: (geometry.clear.x - geometry.next.x - 32.).max(0.),
..geometry.next
},
);
if self.chooser.optional() {
label(
renderer,
"Clear reference",
10.,
style::TEAL,
geometry.clear,
);
}
self.input.as_widget().draw(
self.input_tree,
renderer,
theme,
renderer_style,
layout.children().nth(1).unwrap(),
cursor,
&geometry.popup,
);
});
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
) -> mouse::Interaction {
let geometry = self.geometry(layout);
if cursor
.position()
.is_some_and(|p| geometry.hit(self.chooser, p).is_some())
{
mouse::Interaction::Pointer
} else {
self.input.as_widget().mouse_interaction(
self.input_tree,
layout.children().nth(1).unwrap(),
cursor,
&geometry.popup,
renderer,
)
}
}
}
fn is_input(event: &Event) -> bool {
matches!(
event,
Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_)
)
}
fn measure<R: text::Renderer<Font = Font>>(content: &str, size: f32, font: Font) -> f32 {
R::Paragraph::with_text(text::Text {
content,
bounds: Size::INFINITE,
size: size.into(),
line_height: text::LineHeight::default(),
font,
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::None,
})
.min_width()
}
fn label<R: text::Renderer<Font = Font>>(
renderer: &mut R,
content: &str,
size: f32,
color: Color,
bounds: Rectangle,
) {
let font = renderer.default_font();
let mut display = content.to_owned();
if measure::<R>(content, size, font) > bounds.width {
let ends: Vec<_> = content
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(content.len()))
.collect();
let (mut low, mut high) = (0, ends.len());
while low + 1 < high {
let middle = (low + high) / 2;
if measure::<R>(&format!("{}…", &content[..ends[middle]]), size, font) <= bounds.width
{
low = middle;
} else {
high = middle;
}
}
display = format!("{}…", &content[..ends[low]]);
}
raw_label(renderer, display, size, color, bounds);
}
fn raw_label<R: text::Renderer<Font = Font>>(
renderer: &mut R,
content: String,
size: f32,
color: Color,
bounds: Rectangle,
) {
renderer.fill_text(
text::Text {
content,
bounds: bounds.size(),
size: size.into(),
line_height: text::LineHeight::default(),
font: renderer.default_font(),
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::None,
},
Point::new(bounds.x, bounds.center_y()),
color,
bounds,
);
}
+23 -14
View File
@@ -187,8 +187,8 @@ fn article(topic: Topic) -> Article {
&[
"Choose New in the top bar. Use Project properties to name the model and describe its purpose. Apply changes commits the property draft.",
"On Architecture, choose Create architecture view. Architecture · allocated work shows work inside its performer.",
"Choose Entity in the diagram's Create toolbar. Enter Rescue coordinator, choose Choose position, then click the diagram. Use Actor for an external participant such as a Vessel in distress. You can also right-click a blank area, choose Create Entity here, enter its name and choose Create here.",
"Choose Activity in the same toolbar, name it Assess incident and place it. Select Rescue coordinator and use + Relationship with Allocation to assign Assess incident to it. The allocated-work view then shows the activity inside its performer.",
"Expand the diagram's side palette to see tool names. Choose Entity, click its position in the diagram, enter Rescue coordinator and press Enter. Escape cancels the draft. Use Actor for an external participant such as a Vessel in distress. Right-clicking a blank area also offers Create Entity here.",
"Right-click Rescue coordinator and choose Add activity. Enter Assess incident and press Enter to create the activity and its performer allocation together. Ordinary Activity creation in the palette leaves work unallocated; use + Relationship with Allocation to assign existing work. The allocated-work view shows work inside its performer.",
"Use Add existing to place an element that is already in the model. The initial choices match this view; no search is required. Include already shown offers Locate for elements already placed here.",
"Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.",
"Visit Model checks to review unfinished work. Save all writes the accepted model and applied drafts to your chosen file.",
@@ -262,8 +262,8 @@ fn article(topic: Topic) -> Article {
"Create and allocate work",
&[
"Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.",
"In the diagram's Create toolbar, choose Activity in OA or Function in SA, enter a name and click to place it. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.",
"To assign work to a participant, use + Relationship with Allocation from the participant to the leaf work. Model explorer also offers + Allocated when a participant and the appropriate work kind are selected.",
"In the diagram's side palette, choose Activity in OA or Function in SA, click its position, enter a name and press Enter. Escape cancels without creating an element. Select a card and press F2 to rename it in place. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.",
"Right-click a participant and choose Add activity or Add function to create new work and its allocation together. To assign existing leaf work, use + Relationship with Allocation from the participant to the work. Model explorer also offers + Allocated when a participant and the appropriate work kind are selected.",
"Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.",
],
),
@@ -446,8 +446,8 @@ fn article(topic: Topic) -> Article {
"Describe a path through exchanges",
&[
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
"Open Behavior & scenarios. Create Process in OA or Functional chain in SA using Create element and + At root.",
"Select that behavior in the page's selector. Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
"Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.",
"Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
"In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
"Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.",
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
@@ -489,20 +489,20 @@ fn article(topic: Topic) -> Article {
walkthrough(
"Create a scenario",
&[
"Open Behavior & scenarios, choose Scenario under Create element and add it at a legal root/owner. Select the scenario in the behavior selector.",
"Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.",
"Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.",
"Choose + Message and its style. Call + correlated reply creates both occurrences in one edit. Select the exchange and compatible sending/receiving lifelines. Reply to existing call offers valid unanswered calls.",
"Use Action, Observe state or Fragment for other behavior. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
"Choose a message style and Draw message. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. The form retains the chosen position while you select the exchange and details. Escape cancels the gesture.",
"Choose a message style in the tool selector. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.",
"Call + correlated reply creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
"Choose Add action, Observe state, Reuse scenario or Add fragment and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.",
"In Select / reorder, click an occurrence and drag its ↕ handle to another position within the same sequence. The ordered list's up/down controls provide another way to reorder. Select an occurrence to inspect its particular use of the exchange, including its message values.",
],
),
walkthrough(
"Describe alternatives",
&[
"Choose Fragment and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.",
"Use the Destination selector in subsequent message or action forms to choose the intended operand. New occurrences append to that operand's ordered behavior.",
"Use Reuse scenario to refer to existing behavior. Bind every referenced lifeline to a compatible local lifeline explicitly. Open source follows the original scenario without copying its contents.",
"Choose Add fragment, click its sequence position and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.",
"Click the highlighted position inside an operand to place the next message, action or reference there. The form shows its destination and insertion position. Append to this sequence instead explicitly moves the pending insertion to that sequence's end.",
"Choose Reuse scenario and click its position, then bind every referenced lifeline to a compatible local lifeline explicitly. Select the resulting use and choose Open definition. Back to caller returns to that exact use and its viewport after you apply or cancel pending edits.",
"Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.",
],
),
@@ -543,7 +543,8 @@ fn article(topic: Topic) -> Article {
walkthrough(
"Build a state machine",
&[
"In Behavior & scenarios, create or select a State machine, then choose Create diagram. This places its existing regions and vertices in a saved view. + View creates another independent layout.",
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names. If the participant does not exist yet, cancel and create it in Architecture first.",
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
@@ -696,6 +697,10 @@ fn article(topic: Topic) -> Article {
"Map references by meaning",
"Reference mapping connect exact OA and SA identities used by the proposed change. Similar names are not sufficient. Map the records that play the same engineering role, including enclosing fields in qualified message-value paths. Mapping a referenced record does not authorize creating, moving or editing that record. If it needs a counterpart, establish that correspondence first.",
),
explain(
"Edit stored observations deliberately",
"In a trace's Properties, Edit stored history (advanced)… edits the observations retained by that trace. Choose the fact and enter its Operational and System observations explicitly. Not applicable, an observed absent value, and no stored fact have different meanings. Historical references may retain the exact UUID of a deleted record. Apply changes validates the complete history; Undo restores it. These history edits leave both counterpart records unchanged. Use Review counterpart changes… for Keep/Take decisions about their current engineering content.",
),
walkthrough(
"Review one message value or lifeline binding",
&[
@@ -894,6 +899,10 @@ fn article(topic: Topic) -> Article {
"A property draft is not yet the model",
"Apply changes validates the candidate. When it is rejected, correct the retained draft or use Revert. The canvas continues to show accepted values. Navigation that needs to apply an invalid draft may remain on the current record so your work is not lost. You can read this guide without applying or discarding that draft.",
),
explain(
"A new behavior is still a draft",
"Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
),
explain(
"Aliases and references",
"Stable source aliases must follow the language's identifier rules and avoid reserved words. Use a simple descriptive identifier such as assess_incident. Spaces belong in the displayed Name, not the alias. Reference pickers offer compatible model records; creating the required target first is often the missing step.",
+7 -5
View File
@@ -228,13 +228,15 @@ pub fn view(app: &App) -> View<'_, Message> {
Page::Guide => crate::guide::view(&app.guide),
};
let process_layout = app.topology_context().is_some();
let focus_process = process_layout && app.behavior.topology.focused;
let scenario_layout = app.scenario_context().is_some();
let focus_behavior = (process_layout && app.behavior.topology.focused)
|| (scenario_layout && app.behavior.scenario.focused());
let mut body = if app.page == Page::Home {
row![container(main).width(Fill).height(Fill)].height(Fill)
} else if process_layout {
} else if process_layout || scenario_layout {
// Keep main at the same widget-tree path across Focus toggles. The
// collapsed slot has no controls, so it cannot paint over or hit the canvas.
let process_sidebar: View<'_, Message> = if focus_process {
let process_sidebar: View<'_, Message> = if focus_behavior {
Space::new().width(0).into()
} else {
sidebar.into()
@@ -242,7 +244,7 @@ pub fn view(app: &App) -> View<'_, Message> {
// Row::push would discard the zero-width slot and shift main again.
widget::Row::from_vec(vec![
container(process_sidebar)
.width(if focus_process { 0 } else { 260 })
.width(if focus_behavior { 0 } else { 260 })
.height(Fill)
.clip(true)
.into(),
@@ -253,7 +255,7 @@ pub fn view(app: &App) -> View<'_, Message> {
} else {
row![sidebar, container(main).width(Fill).height(Fill)].height(Fill)
};
if !focus_process && !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
if !focus_behavior && !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
body = body.push(inspector(app));
}
let footer = container(
+2 -1
View File
@@ -2,7 +2,8 @@
Run from any directory with Python 3: semantic_schema.py [--check] [--validate].
--validate additionally runs source-7 structural fixtures with jsonschema.
This generates documentation only, never runtime selectors or model mutations.
The desktop stored-history editor reads the checked-in descriptor for field
shapes. Core types and validation remain authoritative for model mutations.
"""
import copy
import json
+11
View File
@@ -1,9 +1,19 @@
# syntax=docker/dockerfile:1
# Requires --build-context server_notices=<prepared server-notices directory>.
# See docs/linux-packaging.md. Missing/stale notices fail before application build.
FROM rust:1.90-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY LICENSE ./LICENSE
COPY build ./build
COPY crates ./crates
COPY docs/openapi.json ./docs/openapi.json
COPY scripts/package-artifacts.py ./scripts/package-artifacts.py
COPY --from=server_notices / /tmp/server-notices/
RUN python3 scripts/package-artifacts.py verify-notices \
--directory /tmp/server-notices --repository /src --target x86_64-unknown-linux-gnu
RUN cargo build --release --locked -p oa-server
FROM debian:bookworm-slim
@@ -12,6 +22,7 @@ RUN groupadd --gid 10001 arcadia \
&& mkdir /data \
&& chown 10001:10001 /data
COPY --from=builder /src/target/release/arcadia-server /usr/local/bin/arcadia-server
COPY --from=builder /tmp/server-notices/ /usr/share/doc/arcadia-server/
USER 10001:10001
VOLUME ["/data"]
EXPOSE 8787
+22 -7
View File
@@ -1,9 +1,24 @@
.git
target
dist
harness
# Only inputs consumed by this Dockerfile enter the ordinary repository context.
# The server_notices named context is supplied separately and verified in builder.
**
!Cargo.toml
!Cargo.lock
!LICENSE
!build/
!build/**
!crates/
!crates/**
!docs/
!docs/openapi.json
!scripts/
!scripts/package-artifacts.py
!deployment/
!deployment/Dockerfile
!deployment/Dockerfile.dockerignore
# Do not admit generated/private files even inside an allowed source tree.
**/target
**/*.sqlite3
**/.git
**/.env
**/.env.*
**/*.log
.codex
.openai
**/*.sqlite3
+71 -20
View File
@@ -752,7 +752,7 @@ lists all additional fields. <span class="docutils literal">ref</span> means <sp
</tr>
<tr><td><p><span class="docutils literal">oa_scenario</span>, <span class="docutils literal">sa_scenario</span></p></td>
<td><p><span class="docutils literal">viewpoint</span>: ScenarioViewpoint; <span class="docutils literal">context</span>: ref?; <span class="docutils literal">precondition</span>, <span class="docutils literal">postcondition</span>: strings; <span class="docutils literal">steps</span>: occurrence refs</p></td>
<td><p>MixedLegacy for omitted schema-2 source; none; <span class="docutils literal">&quot;&quot;</span>; <span class="docutils literal">&quot;&quot;</span>; <span class="docutils literal">[]</span></p></td>
<td><p>MixedLegacy when viewpoint is omitted, including newly authored source 7 (see section 4.3); none; <span class="docutils literal">&quot;&quot;</span>; <span class="docutils literal">&quot;&quot;</span>; <span class="docutils literal">[]</span></p></td>
</tr>
<tr><td><p><span class="docutils literal">oa_lifeline</span>, <span class="docutils literal">sa_lifeline</span></p></td>
<td><p><span class="docutils literal">represented</span>: participant, work, or role ref?</p></td>
@@ -792,49 +792,71 @@ An Enumeration contains unique string literals. For example:</p>
<pre class="code text literal-block"><code>definition { Enumeration { literals [&quot;Clear&quot;, &quot;Fog&quot;]; }; };
type_ref { Named &#64;weather_classification; };</code></pre>
<p>These are typed constructors with named fields, not embedded JSON documents.
Constructor field names and enum spellings are case-sensitive. When replacing
a constructor, all of its fields MUST be supplied; the containing declaration
defaults do not silently fill a different constructor's missing fields.
During schema-2 compatibility, the new Exchange fields <span class="docutils literal">message_kind</span>,
<span class="docutils literal">reply_to</span> and <span class="docutils literal">values</span> are exceptions: omitted values hydrate as
Asynchronous, none and [] respectively. Canonical output writes them explicitly.
This preserves older schema-2 source/JSON without inventing call correlation or
value annotations. It is not a rule for an unspecified future schema.</p>
Constructor field names and enum spellings are case-sensitive. A selected
constructor uses its own field contract; it does not inherit the payload of a
different constructor from the declaration default. Required fields MUST be
supplied. Nullability and omission are separate properties: <span class="docutils literal">?</span> denotes a
value that can be <span class="docutils literal">none</span>; a constructor's omission contract determines whether
the field may be absent. There is no general all-constructor-fields-required
rule. Other typed constructors use their contracts in the corresponding
sections; the occurrence rules below do not establish defaults for them.</p>
<p>The following table records the source-7 OccurrenceBody contracts. Optional
references may be omitted and then mean <span class="docutils literal">none</span>. Exchange also admits omitted
<span class="docutils literal">message_kind</span> and <span class="docutils literal">values</span>, meaning Asynchronous and <span class="docutils literal">[]</span> respectively.
These omissions are accepted in newly authored source 7, not only in schema-2
compatibility input. Earlier schema admission of constructors and fields is
still governed by its version rules.</p>
<p>Canonical formatting writes the hydrated occurrence references and default
<span class="docutils literal">message_kind</span>, <span class="docutils literal">reply_to</span> and <span class="docutils literal">values</span> explicitly. An absent
<span class="docutils literal">operation</span> remains omitted in canonical output. Lossless no-op updates and
unrelated name edits retain authored omissions; canonical formatting is a
different operation. An admitted incomplete occurrence can still produce model
checks requesting its missing work, exchange, lifeline or referenced scenario.</p>
<table>
<caption>Occurrence body constructors</caption>
<colgroup>
<col style="width: 22.0%" />
<col style="width: 78.0%" />
<col style="width: 18.0%" />
<col style="width: 36.0%" />
<col style="width: 46.0%" />
</colgroup>
<thead>
<tr><th class="head"><p>Constructor</p></th>
<th class="head"><p>Required fields</p></th>
<th class="head"><p>Fields that may be omitted, with their resulting values</p></th>
</tr>
</thead>
<tbody>
<tr><td><p>Exchange</p></td>
<td><p><span class="docutils literal">exchange</span>: exchange relation ref?; <span class="docutils literal">sender</span>, <span class="docutils literal">receiver</span>: lifeline refs?; <span class="docutils literal">message_kind</span>: MessageKind; <span class="docutils literal">reply_to</span>: occurrence ref?; <span class="docutils literal">values</span>: MessageValue records</p></td>
<td><p>None</p></td>
<td><p><span class="docutils literal">exchange</span>: exchange relation ref?, <span class="docutils literal">sender</span>, <span class="docutils literal">receiver</span>: lifeline refs?, <span class="docutils literal">reply_to</span>: occurrence ref?, <span class="docutils literal">operation</span>: OperationUse? → <span class="docutils literal">none</span>; <span class="docutils literal">message_kind</span>: MessageKind → Asynchronous; <span class="docutils literal">values</span>: MessageValue records → <span class="docutils literal">[]</span></p></td>
</tr>
<tr><td><p>Action</p></td>
<td><p><span class="docutils literal">action</span>: activity/function ref?; <span class="docutils literal">lifeline</span>: ref?</p></td>
<td><p>None</p></td>
<td><p><span class="docutils literal">action</span>: activity/function ref?, <span class="docutils literal">lifeline</span>: ref? → <span class="docutils literal">none</span></p></td>
</tr>
<tr><td><p>Control</p></td>
<td><p><span class="docutils literal">kind</span>: Choice, Merge, Fork, Join, Loop</p></td>
<td><p>None</p></td>
</tr>
<tr><td><p>SequenceReference</p></td>
<td><p><span class="docutils literal">sequence</span>: process/functional-chain ref?</p></td>
<td><p>None</p></td>
<td><p><span class="docutils literal">sequence</span>: process/functional-chain ref? → <span class="docutils literal">none</span></p></td>
</tr>
<tr><td><p>ScenarioUse</p></td>
<td><p><span class="docutils literal">scenario</span>: scenario ref?; <span class="docutils literal">bindings</span>: LifelineBinding records</p></td>
<td><p><span class="docutils literal">bindings</span>: LifelineBinding records</p></td>
<td><p><span class="docutils literal">scenario</span>: scenario ref? → <span class="docutils literal">none</span></p></td>
</tr>
<tr><td><p>State</p></td>
<td><p><span class="docutils literal">state</span>: state/mode ref?; <span class="docutils literal">lifeline</span>: ref?</p></td>
<td><p>None</p></td>
<td><p><span class="docutils literal">state</span>: state/mode ref?, <span class="docutils literal">lifeline</span>: ref? → <span class="docutils literal">none</span></p></td>
</tr>
<tr><td><p>Fragment</p></td>
<td><p><span class="docutils literal">operator</span>: Alternative, Parallel, Loop, Optional; <span class="docutils literal">branches</span>: branch occurrence refs</p></td>
<td><p>None</p></td>
</tr>
<tr><td><p>Branch</p></td>
<td><p><span class="docutils literal">guard</span>: string; <span class="docutils literal">occurrences</span>: occurrence refs</p></td>
<td><p>None</p></td>
</tr>
</tbody>
</table>
@@ -1274,9 +1296,21 @@ reference checks.</p>
<p>Omitted schema-2 viewpoints hydrate as MixedLegacy in both source and JSON.
This preserves pre-extension mixed work/participant scenes without discarding
their lifelines or silently assigning a stricter meaning. MixedLegacy emits a
review warning. New graphical factories explicitly select OperationalActivity
or Functional by layer. Authors can select a strict viewpoint after reviewing
the legacy scene; canonical source then records that choice.</p>
review warning. Source parsing also uses MixedLegacy when <span class="docutils literal">viewpoint</span> is
omitted from a newly authored source-7 scenario; it does not infer a strict
viewpoint from the layer or lifelines. Write the intended viewpoint explicitly
when starting a scenario. For example, this new OA source project starts an
activity-based scenario whose lifelines and occurrences can be added later:</p>
<pre class="literal-block">arcadia 7;
project response &quot;Distress response&quot; {
oa_scenario assessment &quot;Assess the distress report&quot; {
viewpoint OperationalActivity;
}
}</pre>
<p>An empty scenario can produce completeness warnings while remaining editable.
The desktop's Behavior <strong>New</strong> flow asks authors to choose one of the layer's
two strict viewpoints explicitly. Authors can also select a strict viewpoint
after reviewing a legacy scene; canonical source then records that choice.</p>
<p>MessageKind is Asynchronous, Call, Reply, Create, Delete, or
<span class="docutils literal">{ Timer { expression &quot;after 5 s&quot;; }; }</span>. A process/chain exchange
contribution MUST remain Asynchronous with no reply correlation. Call/reply,
@@ -2217,6 +2251,23 @@ strings use JSON string escaping, including <span class="docutils literal">\n</s
and Unicode escapes. Literal unescaped newlines inside strings are invalid.
Numbers use JSON decimal/exponent syntax; NaN, infinity, hexadecimal numbers,
leading plus signs, and leading zeroes are invalid.</p>
<p>A small model may begin with engineering declarations alone. This complete
source describes two participants, their work, responsibility and an exchange:</p>
<pre class="literal-block">arcadia 7;
project distress &quot;Distress response&quot; {
oa_actor vessel &quot;Vessel in distress&quot; {}
oa_entity rescue &quot;Rescue coordination&quot; {}
oa_activity report &quot;Report distress&quot; {}
oa_activity assess &quot;Assess incident&quot; {}
allocation vessel_reports &quot;Crew reports&quot; { source &#64;vessel; target &#64;report; }
allocation rescue_assesses &quot;Team assesses&quot; { source &#64;rescue; target &#64;assess; }
interaction distress_report &quot;Distress report&quot; { source &#64;report; target &#64;assess; }
}</pre>
<p>No diagram, placement, empty description or explicit root parent is required.
The accepted source adds identities; keep those clauses during later edits.
Changing <span class="docutils literal">&quot;Assess incident&quot;</span> changes the display name while <span class="docutils literal">&#64;assess</span> still
resolves to the same activity. Behavior-coverage warnings permit this intermediate
model to be saved. Allocation remains explicit and distinct from decomposition.</p>
<p>Aliases match <span class="docutils literal"><span class="pre">[A-Za-z_][A-Za-z0-9_]*</span></span>. The following aliases are reserved:
arcadia, project, id, oa, sa, element, relation, diagram, node, artifact,
trace, body, alias, name, description, parent, from, to, entity, actor,
@@ -2227,7 +2278,7 @@ spellings can be used in alias positions because references begin with <span cla
<span class="docutils literal"><span class="pre">element-kind</span></span> and <span class="docutils literal"><span class="pre">relation-kind</span></span> are exactly the corresponding declaration
keywords listed above. Typed field
sets and enum constructors are closed by sections 3–5.</p>
<pre class="code text literal-block"><code>document = &quot;arcadia&quot; &quot;5&quot; &quot;;&quot; project
<pre class="code text literal-block"><code>document = &quot;arcadia&quot; &quot;7&quot; &quot;;&quot; project
project = &quot;project&quot; alias string identity? &quot;{&quot; project-entry* &quot;}&quot;
project-entry = &quot;description&quot; string &quot;;&quot; | declaration
declaration = element
+76 -19
View File
@@ -120,7 +120,7 @@ lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts
- No implicit contract; ``legacy`` defaults none. OA admits Legacy only.
* - ``oa_scenario``, ``sa_scenario``
- ``viewpoint``: ScenarioViewpoint; ``context``: ref?; ``precondition``, ``postcondition``: strings; ``steps``: occurrence refs
- MixedLegacy for omitted schema-2 source; none; ``""``; ``""``; ``[]``
- MixedLegacy when viewpoint is omitted, including newly authored source 7 (see section 4.3); none; ``""``; ``""``; ``[]``
* - ``oa_lifeline``, ``sa_lifeline``
- ``represented``: participant, work, or role ref?
- none
@@ -155,37 +155,60 @@ An Enumeration contains unique string literals. For example:
type_ref { Named @weather_classification; };
These are typed constructors with named fields, not embedded JSON documents.
Constructor field names and enum spellings are case-sensitive. When replacing
a constructor, all of its fields MUST be supplied; the containing declaration
defaults do not silently fill a different constructor's missing fields.
During schema-2 compatibility, the new Exchange fields ``message_kind``,
``reply_to`` and ``values`` are exceptions: omitted values hydrate as
Asynchronous, none and [] respectively. Canonical output writes them explicitly.
This preserves older schema-2 source/JSON without inventing call correlation or
value annotations. It is not a rule for an unspecified future schema.
Constructor field names and enum spellings are case-sensitive. A selected
constructor uses its own field contract; it does not inherit the payload of a
different constructor from the declaration default. Required fields MUST be
supplied. Nullability and omission are separate properties: ``?`` denotes a
value that can be ``none``; a constructor's omission contract determines whether
the field may be absent. There is no general all-constructor-fields-required
rule. Other typed constructors use their contracts in the corresponding
sections; the occurrence rules below do not establish defaults for them.
The following table records the source-7 OccurrenceBody contracts. Optional
references may be omitted and then mean ``none``. Exchange also admits omitted
``message_kind`` and ``values``, meaning Asynchronous and ``[]`` respectively.
These omissions are accepted in newly authored source 7, not only in schema-2
compatibility input. Earlier schema admission of constructors and fields is
still governed by its version rules.
Canonical formatting writes the hydrated occurrence references and default
``message_kind``, ``reply_to`` and ``values`` explicitly. An absent
``operation`` remains omitted in canonical output. Lossless no-op updates and
unrelated name edits retain authored omissions; canonical formatting is a
different operation. An admitted incomplete occurrence can still produce model
checks requesting its missing work, exchange, lifeline or referenced scenario.
.. list-table:: Occurrence body constructors
:header-rows: 1
:widths: 22 78
:widths: 18 36 46
* - Constructor
- Required fields
- Fields that may be omitted, with their resulting values
* - Exchange
- ``exchange``: exchange relation ref?; ``sender``, ``receiver``: lifeline refs?; ``message_kind``: MessageKind; ``reply_to``: occurrence ref?; ``values``: MessageValue records
- None
- ``exchange``: exchange relation ref?, ``sender``, ``receiver``: lifeline refs?, ``reply_to``: occurrence ref?, ``operation``: OperationUse? → ``none``; ``message_kind``: MessageKind → Asynchronous; ``values``: MessageValue records → ``[]``
* - Action
- ``action``: activity/function ref?; ``lifeline``: ref?
- None
- ``action``: activity/function ref?, ``lifeline``: ref? → ``none``
* - Control
- ``kind``: Choice, Merge, Fork, Join, Loop
- None
* - SequenceReference
- ``sequence``: process/functional-chain ref?
- None
- ``sequence``: process/functional-chain ref? → ``none``
* - ScenarioUse
- ``scenario``: scenario ref?; ``bindings``: LifelineBinding records
- ``bindings``: LifelineBinding records
- ``scenario``: scenario ref? → ``none``
* - State
- ``state``: state/mode ref?; ``lifeline``: ref?
- None
- ``state``: state/mode ref?, ``lifeline``: ref? → ``none``
* - Fragment
- ``operator``: Alternative, Parallel, Loop, Optional; ``branches``: branch occurrence refs
- None
* - Branch
- ``guard``: string; ``occurrences``: occurrence refs
- None
For example, ``body { Action { action @assess; lifeline @operator_line; }; };``
selects an Action occurrence. Processes and functional chains contain Exchange,
@@ -631,9 +654,23 @@ reference checks.
Omitted schema-2 viewpoints hydrate as MixedLegacy in both source and JSON.
This preserves pre-extension mixed work/participant scenes without discarding
their lifelines or silently assigning a stricter meaning. MixedLegacy emits a
review warning. New graphical factories explicitly select OperationalActivity
or Functional by layer. Authors can select a strict viewpoint after reviewing
the legacy scene; canonical source then records that choice.
review warning. Source parsing also uses MixedLegacy when ``viewpoint`` is
omitted from a newly authored source-7 scenario; it does not infer a strict
viewpoint from the layer or lifelines. Write the intended viewpoint explicitly
when starting a scenario. For example, this new OA source project starts an
activity-based scenario whose lifelines and occurrences can be added later::
arcadia 7;
project response "Distress response" {
oa_scenario assessment "Assess the distress report" {
viewpoint OperationalActivity;
}
}
An empty scenario can produce completeness warnings while remaining editable.
The desktop's Behavior **New** flow asks authors to choose one of the layer's
two strict viewpoints explicitly. Authors can also select a strict viewpoint
after reviewing a legacy scene; canonical source then records that choice.
MessageKind is Asynchronous, Call, Reply, Create, Delete, or
``{ Timer { expression "after 5 s"; }; }``. A process/chain exchange
@@ -1582,6 +1619,26 @@ and Unicode escapes. Literal unescaped newlines inside strings are invalid.
Numbers use JSON decimal/exponent syntax; NaN, infinity, hexadecimal numbers,
leading plus signs, and leading zeroes are invalid.
A small model may begin with engineering declarations alone. This complete
source describes two participants, their work, responsibility and an exchange::
arcadia 7;
project distress "Distress response" {
oa_actor vessel "Vessel in distress" {}
oa_entity rescue "Rescue coordination" {}
oa_activity report "Report distress" {}
oa_activity assess "Assess incident" {}
allocation vessel_reports "Crew reports" { source @vessel; target @report; }
allocation rescue_assesses "Team assesses" { source @rescue; target @assess; }
interaction distress_report "Distress report" { source @report; target @assess; }
}
No diagram, placement, empty description or explicit root parent is required.
The accepted source adds identities; keep those clauses during later edits.
Changing ``"Assess incident"`` changes the display name while ``@assess`` still
resolves to the same activity. Behavior-coverage warnings permit this intermediate
model to be saved. Allocation remains explicit and distinct from decomposition.
Aliases match ``[A-Za-z_][A-Za-z0-9_]*``. The following aliases are reserved:
arcadia, project, id, oa, sa, element, relation, diagram, node, artifact,
trace, body, alias, name, description, parent, from, to, entity, actor,
@@ -1596,7 +1653,7 @@ sets and enum constructors are closed by sections 3–5.
.. code-block:: text
document = "arcadia" "5" ";" project
document = "arcadia" "7" ";" project
project = "project" alias string identity? "{" project-entry* "}"
project-entry = "description" string ";" | declaration
declaration = element
+65
View File
@@ -0,0 +1,65 @@
# Linux packaging and deployment inputs
The packaging command stages **already-built** desktop/server executables for `x86_64-unknown-linux-gnu`. It runs on Windows PowerShell 5.1 or PowerShell 7 (including Linux) with Python 3.11+ standard library, Git, and the matching Rust/Cargo dependency cache. It does not build, download, install runtime libraries or launch the application. Python creates and reads back the tar archive; end users do not need Python, PowerShell or Rust.
Build and validate first in a source checkout. The current runtime validation baseline is Ubuntu 24.04 x86-64. A glibc symbol inventory is not a claim that every distribution with that glibc version supports the renderer and dialogs.
```powershell
./scripts/package-linux.ps1 -Target x86_64-unknown-linux-gnu `
-Version 0.2.0-rc1 -BinaryDirectory ./target/release -Python python3
```
For an explicit-target build, use `target/x86_64-unknown-linux-gnu/release`. A cross-build may supply another directory containing both `arcadia-studio` and `arcadia-server`. Select the Rust compiler used for the intended build before packaging; its Linux target libraries, copyright reports and locked source/archive cache must be installed. Read-only `cargo metadata --locked --offline --filter-platform x86_64-unknown-linux-gnu` and Rust version/sysroot queries are the only Rust commands run by packaging. When packaging from Windows, `-Python C:/path/to/python.exe` selects Python explicitly.
The script refuses existing output/claim names. It creates `dist/ArcadiaStudio-<version>-x86_64-unknown-linux-gnu/`, a `.tar.gz`, and an adjacent `.sha256`. A failed attempt leaves its claim and any partial outputs for diagnosis; choose a fresh version or output root rather than overwriting them. Archives use sorted paths, zero timestamps and uid/gid, 0755 for the two Linux executables and 0644 for other files. Every entry is read back and compared against staging, including permissions. The same unchanged inputs on the same Python/zlib implementation produce the same bytes; this does not promise reproducible compiler output.
## Contents and provenance
Both executables, the MIT license, a package-specific README, complete tracked examples (including all water-pressure teaching/evidence files), deployment references and the reachable documentation closure are included. Relative Markdown/reST/HTML file links are checked before archiving; upstream license texts are preserved verbatim and their internal source-tree hyperlinks are outside that documentation check. Untracked private files, caches and unrelated harness history are not included merely because they exist on disk.
`package.json` records input hashes and static ELF dependencies. `SHA256SUMS.txt` inventories every file except itself; the archive checksum covers that inventory too. `dependency-notices.json`, `THIRD-PARTY-NOTICES.txt` and `licenses/` use the same strict collector as Windows: exact target-filtered normal/build dependency graph, locked crate archives, actual source hashes, checksum-bound supplements and embedded fonts, Rust distribution notices and target-library hashes. Unknown sources, absent notices or changed supplements stop packaging. No Linux system libraries are bundled by this pipeline; their runtime distribution supplies them and its notices.
ELF preflight requires little-endian x86-64 executables/PIE, the glibc dynamic loader, no RPATH/RUNPATH and a reviewed set of direct libc/compiler-runtime imports. Unknown libraries require an explicit packaging design instead of being silently copied from a developer machine. Graphics/window/portal libraries loaded at runtime are outside that static import list.
Optionally pass `-BuildEvidence <json-file>`, containing nonempty `source_identity`, `profile`, `compiler`, exact `target`, and `binary_sha256` keyed by `arcadia-studio` and `arcadia-server`. Hash strings must be lowercase SHA256. Target and binary hashes are checked; other fields remain explicit caller assertions. Omission records build provenance as unknown. Never label a development binary an optimized release because of its directory or package version.
## Native extracted-bundle checks
Extract into a fresh user-writable Linux directory and run from an unrelated working directory. Verify SHA256SUMS, file permissions, `file`/`ldd` and actual desktop/runtime libraries. A typical Ubuntu runtime needs X11/XKB and Wayland clients, Vulkan loader/Mesa Vulkan drivers, and working session DBus plus an xdg-desktop-portal backend for file dialogs. A successful `ldd` alone is insufficient because these libraries can be loaded dynamically.
For a software Vulkan trial, first locate the installed lavapipe ICD; only if it exists, use:
```sh
WGPU_BACKEND=vulkan VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json ./arcadia-studio
```
A compatible native GPU may run without these variables. WSLg uses real Linux user space with a particular display environment; record the actual X11/Wayland backend and do not infer support for all Linux sessions from one trial. Exercise Home walkthrough, Help/search, native Open/Save dialogs, a multi-file project, diagram/source edits, Undo/Redo and reopening at normal/minimum window sizes. Compare complete saved model/source and inspect stderr, not merely whether a window appeared.
Run the server against a separate local data directory and unused loopback port. Follow [server configuration](server.md) for health, authentication, ETags, restart and backup/restore. Test create/import, a changed conditional update, unchanged no-op, stale/invalid rejection, and persistence after a clean restart. Record exact binary/archive/source/compiler hashes and platform. Packaging verification is separate from current-tree release acceptance.
## Container server notices and context
The Dockerfile is x86-64 Linux only and requires BuildKit named build contexts. Before its application build it verifies a prepared server-only notice bundle against Cargo.lock, Rust compiler release/commit, exact Linux target-library hashes, notice-file checksums and workspace source hashes. This prevents using Windows dependency notices or a stale source/compiler inventory as the container's notice claim.
In the source checkout, select **Rust 1.90.0** and its `x86_64-unknown-linux-gnu` target libraries to match `rust:1.90-bookworm`. Populate the locked dependency cache through your ordinary documented build/setup first; notice collection itself remains offline. On a Windows host, a Rust 1.90 GNU-host toolchain with installed Linux target libraries can provide this read-only inventory. Prepare a fresh output root:
```powershell
./scripts/package-linux.ps1 -Target x86_64-unknown-linux-gnu `
-ServerNoticesOnly -OutputRoot ./.local/container-notices-rc1
```
This writes `.local/container-notices-rc1/server-notices` and a sibling claim file. Pass only the notice directory as the named context:
```sh
docker buildx build --platform linux/amd64 --load \
--build-context server_notices=./.local/container-notices-rc1/server-notices \
-f deployment/Dockerfile -t arcadia-server:0.2.0-rc1 .
```
The Dockerfile-specific ignore file allows only its ordinary source/build inputs; `.local`, model data, `.env` files and arbitrary caches are excluded from that root context. The separate named context includes only the explicitly prepared notices. The image retains them at `/usr/share/doc/arcadia-server/`, alongside Debian's own distribution notices. Do not pass a broad cache or home directory as the named context. Updating Cargo.lock, workspace sources or the compiler requires regenerating the bundle under a fresh output name.
The image runs as UID/GID 10001 and expects its data volume and token as described in [server configuration](server.md). Verify the actual image build, service health/authentication, writable volume and restart on a Docker-capable Linux runner. A Dockerfile review and successful local package checks do not establish container build/runtime acceptance.
## Runner-independent validation commands
Run `cargo fmt --all -- --check`, `cargo clippy --locked --workspace --all-targets -- -D warnings`, `cargo test --locked --workspace --all-targets`, and `cargo build --release --locked --workspace --bins` with the explicit supported compiler and native prerequisites. Run `python3 scripts/test-package-artifacts.py` and the PowerShell notice contract checks. Then package the resulting binaries and perform the native checks above. These commands are usable on a suitably provisioned runner; this document does not assert that GitLab, Gitea or GitHub has an active runner for this repository.
+5 -3
View File
@@ -1,6 +1,6 @@
# Arcadia Studio server
`arcadia-server` is a headless Rust REST service for the same schema-5 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-7 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.
@@ -68,10 +68,12 @@ Errors use `application/problem+json` with stable `code`, HTTP `status`, human `
## Linux deployment
`deployment/Dockerfile` builds a headless image from the repository root:
Prepare the server-only dependency notices using the pinned Rust toolchain as described in [Linux packaging](linux-packaging.md), then build the headless image from the repository root. BuildKit supplies this checksum-bound notice bundle as a separate context; the ordinary build context excludes local caches and model data.
```sh
docker build -f deployment/Dockerfile -t arcadia-server:local .
docker buildx build --platform linux/amd64 --load \
--build-context server_notices=./.local/container-notices-rc1/server-notices \
-f deployment/Dockerfile -t arcadia-server:local .
docker run --rm --name arcadia-server \
-p 127.0.0.1:8787:8787 \
-v arcadia-data:/data \
+66
View File
@@ -0,0 +1,66 @@
# Source CLI
Use `arcadia` to check or update local `.arcadia` projects without opening Studio
or starting a server. Pass the entry file explicitly; its relative include paths
resolve from their containing source files.
Build with `cargo build --locked -p oa-cli --bin arcadia`. The resulting command
is `target/debug/arcadia.exe` on Windows or `target/debug/arcadia` on Linux unless
your Cargo configuration chooses another target directory. A workspace release
build also includes it. Use the platform's usual executable path prefix when
running these examples.
```sh
arcadia check project.arcadia
arcadia check project.arcadia --json
arcadia stabilize project.arcadia
arcadia stabilize project.arcadia --write
arcadia rename-alias project.arcadia old_alias new_alias
arcadia rename-alias project.arcadia old_alias new_alias --write --json
```
`lint` is an alias for `check`. Checks accept incomplete engineering models with
warnings; syntax errors and structural violations fail. Located diagnostics
refer to the named file and coordinate space. Line and column numbers are
one-based and columns count Unicode scalar values; byte ranges are half-open
UTF-8 offsets.
`stabilize` previews missing persistent identities and any supported source
migration. `rename-alias` changes one existing global alias and its typed
references while preserving comments, strings and unrelated source. Pass alias
names without `@`. The command does not rename an element's display name.
Both editing commands preview by default. Human output writes unified file
diffs to stdout and diagnostics to stderr. `--json` writes one versioned report
with diagnostics, migration information, exact before/after text for every
changed file, diffs and the write result. Unchanged files are not included in
that report. They are still checked for conflicts before a write.
Add `--write` to save the candidate prepared by that invocation. Even an empty
diff checks the captured files and pending-save state before returning. A failed
write reports `written: false` and retains its proposed diff. This does not
guarantee rollback; the reported conflict or recovery outcome determines the
next step. A pending save
requires explicit recovery through the existing project recovery workflow;
the CLI does not recover it automatically.
Missing IDs in a separate earlier preview remain provisional. A later command
may allocate different IDs until you save them. This CLI does not persist a
review plan for later application. It does not canonicalize the whole project
or delete detached files. Source versions 3–7 use the core's supported migration
rules; invalid source is not rewritten.
| Exit code | Meaning |
| --- | --- |
| 0 | Valid check or successful preview/write; warnings may remain |
| 1 | Invalid source or refused semantic alias change |
| 2 | Invalid command arguments |
| 3 | File/path, conflict, pending-save or output failure |
Admission bounds match the desktop: 256 files, include depth 32, 1024 include
edges, 64 MiB total UTF-8, 100,000 graph objects, 250,000 records and 500,000
ordered references. `arcadia --help` lists the commands and bounds. These limits
are not a claim of interactive performance at the maximum project size.
For the notation itself, start with [the text-first examples](../examples/text-first/README.md)
and the [language contract](language.rst).
+7 -5
View File
@@ -2,12 +2,12 @@
The standard build target is x86_64 MSVC. The optional gnullvm build uses LLVM-MinGW UCRT and currently needs `libunwind.dll` alongside both executables. Packaging is a separate operation on already-built inputs; it neither builds nor launches applications and does not confer release acceptance.
Run from a Git checkout with Windows PowerShell 5.1 or PowerShell 7. Build and test first using the [README](../README.md). Select the actual output directory explicitly; a native-host Cargo build normally uses `target/release`, while an explicit `--target` uses `target/<target>/release`.
Run from a Git checkout with Windows PowerShell 5.1 or PowerShell 7 and Python 3.11+ (standard library only). Build and test first using the [README](../README.md). Select the actual output directory explicitly; a native-host Cargo build normally uses `target/release`, while an explicit `--target` uses `target/<target>/release`. `-Python <executable>` selects Python when it is not named `python` on PATH; it collects/checks the package-local documentation closure without running binaries.
Packaging also needs the build's matching `cargo` and `rustc` on the packaging process PATH, the locked dependency sources/archive cache, and installed Rust target libraries and copyright reports. The script invokes only read-only `cargo metadata --locked --offline --filter-platform <target>` and Rust version/sysroot queries. It does not download dependencies or build. These tools are packaging prerequisites; the extracted application does not require them.
```powershell
# Standard MSVC outputs, already built:
# MSVC outputs, already built with a verified runtime strategy:
./scripts/package-windows.ps1 -Target x86_64-pc-windows-msvc `
-Version 0.2.0-rc1 -BinaryDirectory ./target/release
@@ -17,11 +17,13 @@ Packaging also needs the build's matching `cargo` and `rustc` on the packaging p
-LlvmMinGWRoot C:/Tools/llvm-mingw-20260826-ucrt-x86_64
```
The caller must supply the matching build target, version, and final binaries. PE headers establish x86_64 architecture, but do not prove a source revision or Cargo profile. `package.json` records input hashes and inspected imports; retain build/test evidence tied to those hashes separately. The version labels the package and does not change the embedded application version.
The caller must supply the matching build target, version, and final binaries. PE headers establish x86_64 architecture, but do not prove a source revision or Cargo profile. `package.json` records input hashes and inspected imports; retain build/test evidence tied to those hashes separately. The version labels the package and does not change the embedded application version. Optional `-BuildEvidence <json>` requires nonempty `source_identity`, `profile`, `compiler`, exact `target`, and `binary_sha256` keyed by both executable filenames. Target and lowercase SHA256 values are checked; source/compiler/profile remain explicitly supplied assertions, never inferred from a directory name.
Ordinary dynamic-CRT MSVC output can import VC redistributables and will be refused by the strict runtime check. One candidate recipe is an explicit-target build with `CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS=-C target-feature=+crt-static`; validate its actual imports and runtime on the intended platform before packaging. This guide does not assert that such a build has been performed. The existing GNU/LLVM recipe has its own exact libunwind staging below. Do not silence an unknown import or copy arbitrary DLLs to make packaging pass.
The script creates a fresh `dist/ArcadiaStudio-<version>-<target>/`, matching ZIP, and ZIP `.sha256` file. `-OutputRoot` selects another location. Existing output names are refused; no directories are deleted or overwritten. A `.packaging.lock` claim and partial outputs remain after failure for inspection; retry under a fresh version/output location. ZIP file order and timestamps are fixed so unchanged inputs can be compared across runs; this is not a reproducible compiler-build guarantee.
Each package includes desktop and server executables, README, MIT license, built-in Help in the application, tracked docs (including language/API specifications and OpenAPI), tracked examples and deployment recipes, current coverage/runtime notes, START-HERE, provenance, and SHA-256 inventory. It includes current working-tree contents of tracked files, so review uncommitted documentation edits before packaging. Untracked sample/user files are excluded. Repository sources are needed separately for the documented Cargo and Docker build recipes.
Each package includes both executables, a package-specific README, MIT license, built-in Help, language/API specifications and OpenAPI, server/platform guides and their reachable documentation closure, all tracked examples and deployment references, START-HERE, provenance, and SHA256 inventory. Relative Markdown/reST/HTML file links are checked before archiving; upstream license texts retain their own source-tree links unchanged. Current working-tree contents of tracked inputs are copied, so review uncommitted documentation edits before packaging. Untracked sample/user files are excluded. Repository sources and tools are needed separately for Cargo/Docker recipes; the archive README makes this distinction explicit.
For gnullvm, the exact selected toolchain's x86_64 `libunwind.dll` and full distribution `LICENSE.TXT` are included; both are hashed. Standard MSVC packaging has no LLVM-MinGW coupling. The script reads ordinary PE imports recursively for packaged runtime DLLs, accepts a bounded list of Windows system components/API sets, and fails on unknown imports. For example, a build importing a VC redistributable DLL must have its redistribution and dependency handling resolved before packaging; it is not silently treated as part of Windows. Do not copy arbitrary DLLs from System32. Dynamically loaded graphics libraries and delay-load imports require the runtime check below.
@@ -64,6 +66,6 @@ try {
}
```
This snippet only starts processes; it does not verify readiness. Check the server's health route documented in [server setup](server.md), import/read/update a model, restart with the same temporary store, and verify persistence. Close only the processes started for this smoke. Exercise the desktop's Home/example/open/save/reopen and Help, inspect real diagrams, and perform the [Save-as Undo/Redo recheck](../harness/release-save-as-runtime-packaging.md). Close normally and check stderr/exit state. A process that survives a few seconds alone is not adequate GUI evidence.
This snippet only starts processes; it does not verify readiness. Check the server's health route documented in [server setup](server.md), import/read/update a model, restart with the same temporary store, and verify persistence. Close only the processes started for this smoke. Exercise Home/example/open/save/reopen and Help, inspect real diagrams, and verify a graphical edit → Save as → Undo → Redo → Save → reopen against the complete intended model/source. Close normally and check stderr/exit state. A process that survives a few seconds alone is not adequate GUI evidence.
Acceptance is pending until these checks are actually performed against the packaged final binaries. Historical V2 bundles, this machine's Cargo checks, and a successful development launch with compiler PATH are separate evidence.
+2 -2
View File
@@ -65,12 +65,12 @@ The integration oracle executes this exact semantic review and source update. It
| `oa/`, `sa/` | Participants, work, data, capabilities and reusable behavior |
| `views/` | Five saved views; filters and placements are presentation state |
| `traceability.arcadia` | OA↔SA correspondence and review history |
| `model.json` | Current schema-6 interchange of the final accepted model |
| `model.json` | Current schema-7 interchange of the final accepted model |
| `transition-before.json`, `transition-request.json` | Explicit first-transition replay inputs |
| `evidence/repair-check.md` | Local teaching evidence linked from the SA conversation |
| `model.rs` | Deterministic lesson builder shared by generator and tests |
The REST routes are documented in [the API reference](../../docs/api.rst). Import `model.json` with `POST /api/v1/projects/import`; query the returned resource's `/model` and `/validation`. Expect schema 6 and the one warning above. JSON import provides canonical source; to retain the exact modular source layout, use `/api/v1/projects/import-source-project` with a SourceBundle containing entry `project.arcadia` and only the 30 reachable `.arcadia` file strings, keyed by slash-separated relative paths. Neither route reads the artifact target from your local filesystem.
The REST routes are documented in [the API reference](../../docs/api.rst). Import `model.json` with `POST /api/v1/projects/import`; query the returned resource's `/model` and `/validation`. Expect schema 7 and the one warning above. JSON import provides canonical source; to retain the exact modular source layout, use `/api/v1/projects/import-source-project` with a SourceBundle containing entry `project.arcadia` and only the 30 reachable `.arcadia` file strings, keyed by slash-separated relative paths. Neither route reads the artifact target from your local filesystem.
For transition replay, import `transition-before.json` instead. Submit `transition-request.json` to that baseline resource's `/transitions/preview` with its current ETag in `If-Match`. Review the returned plan, then follow the documented `/transitions/apply` protocol. Use the baseline resource for this exercise: the final model already contains the mapped records and subsequent refinements. This tutorial's automated evidence is core/source validation; it does not claim a separate native or HTTP acceptance run of this particular example.

Some files were not shown because too many files have changed in this diff Show More