Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69bb778f01 | ||
|
|
f1d79df497 | ||
|
|
bed5b0e832 | ||
|
|
a2a28f8d23 | ||
|
|
472fe7a433 | ||
|
|
6de3b53103 | ||
|
|
b2c970b467 | ||
|
|
021bdc5a92 | ||
|
|
6216cff4cf | ||
|
|
9ff6e7cf7a | ||
|
|
2c515cf3bd | ||
|
|
e8a18a7cf9 | ||
|
|
02b9d5ce4c | ||
|
|
112e0d910b | ||
|
|
bff8014c1f | ||
|
|
df3f4072dd | ||
|
|
1513aa2794 | ||
|
|
3174e24eea | ||
|
|
847b1caa7e | ||
|
|
cdebb49d89 | ||
|
|
b96ff3935a |
@@ -44,3 +44,6 @@ harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at-
|
||||
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,14 +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.
|
||||
- Publish versioned Gitea releases at meaningful, working milestones so the user can see progress and try the application. Include runnable builds or appropriate distributable artifacts, concise release notes, launch instructions, known limitations, and what was verified. Mark unfinished versions as prereleases. Coordinate release ownership to avoid duplicates.
|
||||
- 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
|
||||
|
||||
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
|
||||
|
||||
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
|
||||
|
||||
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
|
||||
|
||||
## Delegation and reasoning
|
||||
|
||||
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
|
||||
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
|
||||
|
||||
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
|
||||
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
|
||||
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
|
||||
|
||||
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
|
||||
|
||||
## Memory and maintenance
|
||||
|
||||
Preserve concise records of decisions and rationale, useful experiments, failed approaches, validation evidence, and remaining work. Link substantial evidence rather than copying logs into indexes. Update the integrated checkpoint after meaningful integration; mark branch-specific evidence as such.
|
||||
|
||||
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
|
||||
|
||||
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
|
||||
|
||||
## Delivery
|
||||
|
||||
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
|
||||
|
||||
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
|
||||
|
||||
Generated
+42
@@ -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"
|
||||
@@ -2178,6 +2187,7 @@ dependencies = [
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"stacker",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"uuid",
|
||||
@@ -2508,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"
|
||||
@@ -2731,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"
|
||||
@@ -3451,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"
|
||||
|
||||
+38
-606
@@ -1,616 +1,49 @@
|
||||
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
|
||||
|
||||
### 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
|
||||
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.
|
||||
Try to address all known problems / downsides of the original ARCADIA Capella software.
|
||||
|
||||
Version 1 MVP in the form of working Operational Analysis layer is done.
|
||||
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
|
||||
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
|
||||
Version 1 MVP in the form of working Operational Analysis layer is done.
|
||||
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
|
||||
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
|
||||
Remaining ARCADIA layers will be added in future releases.
|
||||
|
||||
Transitioning of model elements between layers (in both directions) should be easy and intuitive.
|
||||
@@ -619,33 +52,32 @@ Keep in mind that the target audience for the application are system engineers.
|
||||
|
||||
The application should be a general systems modeling tool, like ARCADIA Capella or SysML v2
|
||||
|
||||
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
|
||||
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
|
||||
|
||||
The original ARCADIA Capella UI is very bloated, clunky, unintuitive and hard to use. In addition, it lacks a number of quality of life features. You should at all cost avoid making the same mistakes as the developers of the original ARCADIA Capella
|
||||
|
||||
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
|
||||
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
|
||||
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
|
||||
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
|
||||
|
||||
To explore the original ARCADIA Capella UI & semantics you may use web resources / web search / official and community docs. Or open the original ARCADIA Capella installed on this desktop machine for exploration
|
||||
|
||||
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
|
||||
The application should support both forms of editing
|
||||
The textual representation should be easily editable and understandble for both humans and AI
|
||||
The textual representation should be easily handled by version control tools such as Git
|
||||
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
|
||||
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
|
||||
The application should support both forms of editing
|
||||
The textual representation should be easily editable and understandble for both humans and AI
|
||||
The textual representation should be easily handled by version control tools such as Git
|
||||
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
|
||||
The textual representation editor should have syntax highlighting and linting
|
||||
|
||||
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
|
||||
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
|
||||
The 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
|
||||
@Computer
|
||||
|
||||
### Semantic model / metamodel
|
||||
|
||||
Requirements:
|
||||
|
||||
- Textual notation.
|
||||
- Graphical notation.
|
||||
- Constraints and calculations.
|
||||
@@ -658,7 +90,6 @@ Requirements:
|
||||
#### 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.
|
||||
@@ -672,8 +103,9 @@ Long, homogeneous lists in the UI that require user to scroll far or search for
|
||||
|
||||
## 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
|
||||
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
|
||||
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 -->
|
||||
|
||||
@@ -7,7 +7,8 @@ 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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¶meter.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(¶meter.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[¶meter.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),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ impl<K> NumericType<K> {
|
||||
matches!(self, Self::Named(kind) if kind.is_affine())
|
||||
}
|
||||
|
||||
fn anonymous(dimension: Dimension) -> Self {
|
||||
pub fn anonymous(dimension: Dimension) -> Self {
|
||||
if dimension.is_dimensionless() {
|
||||
Self::Number
|
||||
} else {
|
||||
@@ -161,7 +161,7 @@ impl<K> NumericType<K> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(self) -> Self {
|
||||
pub fn normalized(self) -> Self {
|
||||
match self {
|
||||
Self::Anonymous(dimension) => Self::anonymous(dimension),
|
||||
other => other,
|
||||
@@ -169,6 +169,93 @@ impl<K> NumericType<K> {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -223,7 +310,7 @@ impl<K: Clone + Eq> NumericValue<K> {
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, false)?;
|
||||
let output = self.value_type.additive_result(&rhs.value_type, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.add(&rhs.coordinate, meter)?,
|
||||
@@ -232,44 +319,18 @@ impl<K: Clone + Eq> NumericValue<K> {
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, true)?;
|
||||
let output = self.value_type.additive_result(&rhs.value_type, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.subtract(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn additive_type(&self, rhs: &Self, subtract: bool) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::{Anonymous, Named, Number};
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(Anonymous(left), Anonymous(right)) if left == right => Ok(Anonymous(*left)),
|
||||
(Named(left), Named(right)) => match (&left.difference, &right.difference) {
|
||||
(None, None) if left == right => Ok(Named(left.clone())),
|
||||
(Some(difference), Some(_)) if subtract && left == right => Ok(Named(
|
||||
ResolvedKind::linear(difference.clone(), left.dimension),
|
||||
)),
|
||||
(Some(difference), None)
|
||||
if difference == &right.key && left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(left.clone()))
|
||||
}
|
||||
(None, Some(difference))
|
||||
if !subtract
|
||||
&& difference == &left.key
|
||||
&& left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(right.clone()))
|
||||
}
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
},
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, false)?;
|
||||
let output = self
|
||||
.value_type
|
||||
.multiplicative_result(&rhs.value_type, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.multiply(&rhs.coordinate, meter)?,
|
||||
@@ -278,82 +339,36 @@ impl<K: Clone + Eq> NumericValue<K> {
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, true)?;
|
||||
let output = self
|
||||
.value_type
|
||||
.multiplicative_result(&rhs.value_type, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.divide(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn multiplicative_type(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
divide: bool,
|
||||
) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::Number;
|
||||
if self.value_type.is_affine() || rhs.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(_, Number) => Ok(self.value_type.clone()),
|
||||
(Number, _) if !divide => Ok(rhs.value_type.clone()),
|
||||
_ => {
|
||||
let left = self.value_type.dimension();
|
||||
let right = rhs.value_type.dimension();
|
||||
let dimension = if divide {
|
||||
left.checked_quotient(right)?
|
||||
} else {
|
||||
left.checked_product(right)?
|
||||
};
|
||||
Ok(NumericType::anonymous(dimension))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.negated(meter)?,
|
||||
))
|
||||
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()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.absolute(meter)?,
|
||||
))
|
||||
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()?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
let output = match exponent {
|
||||
0 => NumericType::Number,
|
||||
1 => self.value_type.clone(),
|
||||
_ => NumericType::anonymous(self.value_type.dimension().checked_power(exponent)?),
|
||||
};
|
||||
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()?;
|
||||
if self.value_type != rhs.value_type {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
self.value_type.comparison_compatible(&rhs.value_type)?;
|
||||
self.coordinate.compare(&rhs.coordinate, meter)
|
||||
}
|
||||
|
||||
@@ -363,16 +378,8 @@ impl<K: Clone + Eq> NumericValue<K> {
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if target.is_affine()
|
||||
|| matches!(self.value_type, NumericType::Named(_))
|
||||
|| self.value_type.dimension() != target.dimension
|
||||
{
|
||||
return Err(NumericError::KindAssertion);
|
||||
}
|
||||
let output = self.value_type.assertion_result(target)?;
|
||||
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(target.clone()),
|
||||
self.coordinate.clone(),
|
||||
))
|
||||
Ok(Self::new(output, self.coordinate.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,113 @@
|
||||
//! 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 lex;
|
||||
mod lower;
|
||||
mod syntax;
|
||||
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,154 @@
|
||||
//! 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(())
|
||||
}
|
||||
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 = ¶meters[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,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)]
|
||||
pub(super) enum Target {
|
||||
Global(Id),
|
||||
Parameter {
|
||||
calculation: Id,
|
||||
parameter: a::ParameterId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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)]
|
||||
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)]
|
||||
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(¶meter.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(¶meter.identity, parameter.name.span)?);
|
||||
if names.contains_key(¶meter.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][¶meter.name.name];
|
||||
self.occurrence(
|
||||
¶meter.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(¶meter.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,432 @@
|
||||
//! 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 {
|
||||
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 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,503 @@
|
||||
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 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!("ed[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,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(¶meter.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(¶meter.name)?;
|
||||
self.text(" id \"")?;
|
||||
self.text(¶meter.id.0.to_string())?;
|
||||
self.text("\" : ")?;
|
||||
self.type_value(¶meter.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(¶meter.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(¶meter.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[¶meter.id],
|
||||
Parent::Root,
|
||||
depth + 1,
|
||||
));
|
||||
tasks.push(Text(": "));
|
||||
tasks.push(Alias(¶meter.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(°raded)
|
||||
.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("ed, "second");
|
||||
quoted.definitions.get_mut(&unit_id).unwrap().name = "雪\n\"".into();
|
||||
let text = writer("ed).definition(unit_id).unwrap();
|
||||
for (maximum, pass) in [(text.len(), true), (text.len() - 1, false)] {
|
||||
assert_eq!(
|
||||
Writer::new(
|
||||
"ed,
|
||||
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);
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis;
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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))); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Source8 containment and staged syntax evidence
|
||||
|
||||
This directory preserves branch-specific design and source-review evidence. It is not a live task ledger or an integrated/released capability claim. All copied Markdown files retain their original bytes, source pins and historical statements; `copied-evidence-sha256.json` records verified copy hashes.
|
||||
|
||||
Current writer guidance: [Canonical Negate correction](writer-canonical-negate-20260913.md) supersedes only the r2/S2 recommendation to parenthesize every unary Negate operand. It records the frame-limit counterexample, accepted prefix spelling and required tests. This is a design/source-review correction; writer implementation and executable roundtrip verification remain outstanding.
|
||||
|
||||
- Input analysis candidate: `847b1caa7e75bb82f6ce5193c45bcfc45973ad1a`.
|
||||
- Checked-source-save containment S0: `112e0d910bd2b2c9a191f29536fecb0fbc0177b0`.
|
||||
- Private test-staged expression syntax S1: `02b9d5ce4c73d0446146b13b46a228e6fd4d7735`.
|
||||
- Instruction/document-history merge: `e8a18a7cf97a9ebf4992f00521d06792d6c012e4`; implementation bytes remain those of S1.
|
||||
- The commit adding this directory changes only tests and evidence: it strengthens exact mixed-precedence child topology and flat Binary128/129 plus completed-deep-child failure cleanup. It does not change the S0/S1 implementation.
|
||||
|
||||
Preserved reports:
|
||||
|
||||
- [Revised design](analysis-source8-repair-design-r2-20260913.md): root-selected syntax/oracle corrections, proposed resource calibration and implementation partitions. Its mandatory Negate-grouping recommendation is superseded by the [canonical writer correction](writer-canonical-negate-20260913.md); the original copied design remains unchanged for provenance. Resource numbers are experimental candidates, not measured adoption.
|
||||
- [S0 independent review](analysis-source-save-guard-independent-review-20260913.md).
|
||||
- [S1 independent review](analysis-source8-s1-independent-review-20260913.md).
|
||||
- [Original S0/S1 source handoff](analysis-source8-s0-s1-handoff-20260913.md): exact scope and the original four S0/nine S1 tests. Historical `.local` paths and worktree statements in copied reports are provenance, not current branch status.
|
||||
|
||||
The added tests are `language::analysis_source::tests::mixed_precedence_and_grouping_have_independent_expected_child_topology` and `language::analysis_source::tests::flat_binary_depth_and_malformed_constructor_retain_bounded_completed_children`. There are now eleven staged syntax tests plus four source-save guard tests. The existing public checked-save regression remains unchanged and the public parser still rejects source8; its save refusal is expected until the complete adapter is implemented.
|
||||
|
||||
Only source inspection, rustfmt and whitespace/copy-hash checks were performed for this checkpoint. **Compilation, tests, timing/RSS and native acceptance are unrun here.** The independent reviews found no blocking source defect at their stated pins; they are not execution evidence. Root retains the compiler lease, final migration/version/resource adoption and integration/publication ownership. JSON/depth-switch behavior, model types, public source header admission and ordinary tokenizer implementations are unchanged.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Independent review: S0 source-save containment
|
||||
|
||||
Reviewed exact Git blobs at `112e0d910bd2b2c9a191f29536fecb0fbc0177b0`, parent `847b1caa7e75bb82f6ce5193c45bcfc45973ad1a`. The author's changing worktree/S1 source was not used. No compilation, tests, application or tracked edits were performed. Findings below are source conclusions, not executed results.
|
||||
|
||||
**No actionable production correctness defect found within S0's guard-only scope.** The change prevents unreopenable or lossy generated source from reaching filesystem staging. It does not complete source8 or make this branch ready for ordinary source-save acceptance. One bounded public-entrypoint test coverage gap remains worth closing alongside S1; it does not invalidate the inspected guard implementation.
|
||||
|
||||
## Verified guard ordering and scope
|
||||
|
||||
At `persistence.rs:935–957`, public `save_checked` passes the existing DOCUMENT profile and unchanged depth-admission flag through `save_checked_profile` to the new production helper, supplying the real `language::format`. Thus the test seam contains the actual production guard, not a separate test-only reconstruction.
|
||||
|
||||
At `969–1000`, the helper validates the requested Project, selects source using the existing case-insensitive `.arcadia` extension rule, generates bytes, appends a newline only when missing, and passes those exact final bytes to public `decode_with_report_limited`. The effective byte limit is `profile.bytes` (DOCUMENT64MiB; smaller test profiles retain their exact limit). Other ParseLimits stay unrestricted because save_checked has no caller-supplied graph/record policy; S0 does not invent one. It then requires the decoded **entire Project** to equal the requested Project and refuses any migration report. The equality includes the new definitions catalog; no UUID mask, selected-field comparison or formatter-derived model expectation is used.
|
||||
|
||||
All those operations precede parent resolution and `NamedTempFile::new_in` at1006, writes/sync at1007–1008, conflict inspection at1009 and persist at1017. Decoder/validation failures return their existing typed error; lossy-output and unexpected-migration cases return distinct Invalid errors. There is no public decoder→save recursion introduced by this path. A generated source larger than the byte cap is rejected before decoding/IO, although the existing formatter still constructs its String before that cap check: this is not a new bounded-output allocator or a proof of arbitrary formatter stack/memory safety.
|
||||
|
||||
The `depth_128` parameter continues to govern the JSON encoder. Source admission deliberately uses the public reader's production setting rather than the private candidate-depth override, matching the requested public save/reopen invariant. `json_codec::DEPTH_128_VALIDATED` remains false; no deeper-profile acceptance is implied. Existing source parser nesting constraints and formatter behavior remain separate from this small guard.
|
||||
|
||||
## Sentinel evaluator assessment
|
||||
|
||||
The four new tests in `persistence/source_save_tests.rs` call `save_checked_profile_with_source`, injecting formatter output into the **same guard used by production**. They do not call the outer public `save_checked` function or exercise its real formatter output. This distinction should remain explicit in any test-result summary.
|
||||
|
||||
The evaluator is meaningful: each case tests an existing sentinel destination, a new destination and a destination under a missing parent; it requires the intended non-IO error, unchanged root membership, exact sentinel bytes and absence of new output. The missing-parent case is especially useful: moving NamedTempFile creation ahead of decoding would yield Io and fail the expected-error assertion even if temporary-file Drop would otherwise hide creation in the existing-parent cases. Inspection confirms no other filesystem effect precedes the guard.
|
||||
|
||||
- Malformed output (`arcadia`) checks public parser refusal, rather than manufacturing an arbitrary error.
|
||||
- The lossy-output case adds a valid unused QuantityKind definition while injected source preserves familiar ID/name/elements and omits the definition. Full equality must reject it with the specific loss error. This catches an implementation that compares only the old graph fields. The equality check intentionally precedes migration refusal, so a schema7 decoded candidate can expose catalog loss distinctly.
|
||||
- The equal-project legacy case reaches the separate migration refusal. Ordinary source7 decoding yields the current-schema8 Project with a migration report; equal meaning does not excuse emitting a legacy source representation.
|
||||
- The newline test puts the unterminated UTF-8 source exactly at the byte cap, forcing the appended newline to exceed it. A preterminated version at the new exact cap reaches migration refusal instead, catching either checking before append or appending twice.
|
||||
|
||||
**Coverage note, not a demonstrated production bug:** none of these new sentinel checks protects the outer public call wiring against a later bypass of the shared helper. The existing public regressions at `review_regressions.rs:454,472,490` are useful, but all iterate `.arcadia` before JSON and currently unwrap the first source save. At S0 they are expected to stop at that first save rather than reaching their JSON/conflict cases. Consequently neither passing these four new tests nor the still-failing public regression establishes current JSON/conflict execution coverage. The smallest later check is one public save_checked end-to-end invariant/sentinel case using the real formatter, plus separate existing JSON/conflict cases so a source failure cannot skip them. After S1, retain the fault-injection tests and run the ordinary public save/update/no-op/conflict/deletion regression with source and JSON both observed. Do not weaken the public regression to accept permanent source failure.
|
||||
|
||||
## JSON, conflicts and integration limitation
|
||||
|
||||
The only production diff moves existing save logic into the private shared helper and adds the source-only check. JSON still calls the same encoder with the same profile/depth arguments. Existing newline, temporary write/sync, exact-byte conflict matching, missing-file Conflict and persist code are unchanged. Source admission now takes precedence over inspecting a stale destination; that is necessary for containment and preserves the external bytes, but it means S0 cannot demonstrate source Conflict for an unreopenable formatter output. This is source-based invariance, not a test pass.
|
||||
|
||||
At this exact commit, `language::format` writes schema8 while the language parser still accepts only3–7 (`language.rs:1410`). Therefore ordinary `.arcadia` saves remain unavailable, now safely returning an error before a destination is created/replaced. The previously executed847 regression failed after writing unreadable source; S0 is expected to fail its first unwrap instead. That changed failure position is a **prediction until executed**, and is containment, not repaired source8 round-trip capability. S1 must supply the complete current source representation/parser before normal source saving can be accepted. No JSON128 or broader analysis acceptance follows from this review.
|
||||
|
||||
## Exact input identities
|
||||
|
||||
| Git blob path | SHA256 |
|
||||
| --- | --- |
|
||||
| `crates/oa-core/src/persistence.rs` | `d7b33495e429a0bff65782be4f5295f8fedbd4f72ec394250919bb80ccf79d9e` |
|
||||
| `crates/oa-core/src/persistence/source_save_tests.rs` | `a8eb528ac8f96c97292124409908476c25504303b11bd30b4c0358412e88edda` |
|
||||
| `crates/oa-core/src/persistence/json_codec.rs` | `2aa2c52eea794acb85744440192df9ef5193160ad6ebd1febaa824e144aadd90` |
|
||||
| `crates/oa-core/src/language.rs` | `b65098321ecf79e4dc1182a7fab7bafeec0a238bb56153e81782f04fab838558` |
|
||||
| `crates/oa-core/tests/review_regressions.rs` | `f154e5c88d35e31512e2b07733a1327ea3e2fcfd1ef8eb23066a4e9573f387c1` |
|
||||
|
||||
The commit changes only persistence.rs and the new four-test source file. No S1 bytes were reviewed or endorsed. Execution and parent integration remain pending.
|
||||
@@ -0,0 +1,292 @@
|
||||
# Source8 persistence repair: r2 implementation proposal
|
||||
|
||||
2026-09-13. Author: Codex-956atf /root/nested_call_design. **Proposal only; root owns adoption, version/migration and combined acceptance.** Read-only investigation of isolated `.local/analysis-core-worktree-20260911`, exact clean commit `847b1caa7e75bb82f6ce5193c45bcfc45973ad1a`. No source changes, compiler, tests, UI or publication were performed. This report is outside that worktree.
|
||||
|
||||
This revision preserves the original design at `analysis-source8-repair-design-20260913.md`, SHA256 `db2e2662e5b25d24ca9a6a9cea1d7f97b18e714ffcd052e9dfc387b5fe7b3089`, and responds to `analysis-source8-repair-independent-review-20260913.md`. Root has selected significant-token literal trivia, independently specified display names, call-site-qualified argument spans, comparison-child parentheses and **short declaration words only**. Those choices are the r2 contract. The resource values/calibration and staged implementation partition below remain concrete proposals for root acceptance before coding.
|
||||
|
||||
## Outcome and boundary
|
||||
|
||||
Implement one typed source adapter for the existing neutral definition catalog and all three existing analysis element kinds, together with token-preserving source updates and a public-decode equality guard before checked-save replaces a destination. Keep schema8; do not introduce a further version or enable `DEPTH_128_VALIDATED`. Header acceptance alone is neither a repair nor an acceptable intermediate release.
|
||||
|
||||
The slice is complete for persistence of the candidate's **existing local static analysis vocabulary**, including every Expr variant. It does not add an evaluator, imported package resolver, native forms, historical analysis facets or unsupported transition semantics. It must not be called complete source/API/native parity or evaluated engineering behavior. The accepted architecture already requires those later capabilities.
|
||||
|
||||
Authoritative inputs read:
|
||||
|
||||
- Root `harness/release-analysis-checked-save-20260913.md`: actual public checked-save regression failed at decode after successfully writing source8. The source experiment is 0 passed/1 failed, not a private JSON-codec failure.
|
||||
- Candidate `harness/DECISIONS.md:323,380`: accepted property-owned bases, complete-case overrides, shared checks, pure scoped calculations, exact rational AST preservation, closed Local environment, schema8 allocation, frozen older envelopes and bounded decoding.
|
||||
- Candidate `harness/release-analysis-production-integration-plan-20260911.md`: complete response-budget vertical slice and typed source-adapter direction. Its earlier references to schema7-only production and unimplemented DTOs describe the earlier input; they must not replace the actual 847 source below.
|
||||
- Candidate `PROJECT_GOAL.md:631–671`: equivalent text/graphical representations, human/AI editing, Git-friendly low-noise text, quantities/units/calculations and an explicit API.
|
||||
- [Issue7 comment785](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/7#issuecomment-785), read through Gitea MCP: Scenario peer owns typed optional Diagram payload, layout/reference/source-row changes and gesture feasibility; root owns generic codec, aggregate limits and final version/migration. Neither unfinished contribution may inherit the other's broken version contract.
|
||||
|
||||
## Verified implementation facts
|
||||
|
||||
| Actual candidate source | Fact and repair implication |
|
||||
| --- | --- |
|
||||
| `model.rs:9`; `analysis/types.rs` | Schema8 already exists. DefinitionBody is QuantityKind/Unit/Calculation; DefinitionRef has only Local. ParameterId belongs to a calculation, not global identity. Keep the wire/model types unchanged. |
|
||||
| `analysis.rs:17–23`; `analysis/admission.rs:10,66,95,469` | Logical depth128, closure65536, list1024, calls32 and a provisional aggregate static-event ceiling already exist. Admission checks names/parameter IDs, pure calls, declared types, all cases and dependencies. Reuse this admission; do not typecheck a second AST independently. |
|
||||
| `language.rs:273,593,813,1353,1408,1652` | DeclKind has no definition variant; formatting omits definitions; generic values pass through serde_json::Value; source admits3–7 and rejects analysis elements. A required-body constructor cannot be obtained through ElementBody::new. |
|
||||
| `analysis_numeric/exact.rs:76–124` | Exact has an existing canonical literal writer and metered literal parser. Negative Number literals print `ratio(-n, d)`; leading minus belongs to Unary Negate. Decimal source may normalize to a rational without losing the Expr shape. |
|
||||
| `source_project.rs:92,780,815,1122,1572,1662` | SourceIndex has declaration/top-level field spans, but no typed expression/parameter paths. Updates recompile their public input index before trusting it. Definition IDs currently fall through the record-class test to the node-placement category and canonical ownership to artifacts. Both need explicit definition handling. |
|
||||
| `source_project/source_migration.rs:4` | Existing migration makes token edits, changes the version, preserves module/trivia ownership, then recompiles and compares the whole migrated Project. Extend that route; do not flatten older authored bundles. |
|
||||
| `migration_v7.rs`; `persistence.rs:178–193,449–486` | Frozen schema5/6/7 top-level JSON envelope already exists, rejects analysis kinds and adapts to an empty definitions catalog. JSON8 requires the current catalog. These are implemented candidate facilities, not work to re-create. |
|
||||
| `persistence.rs:952–979` | save_checked_profile validates the Project, formats source or encodes JSON, writes/syncs a temp file, compares expected original bytes and persists. The source branch does not first prove public reopening equality. |
|
||||
| `source_files.rs:819,854` | Modular save already recompiles the exact candidate bundle before its checked filesystem transaction. Retain this stronger bundle boundary; a flat source save is not a substitute for it. |
|
||||
|
||||
Read-file SHA256 identities, in addition to the exact commit: types `27363b1755f3c0943685a9837c69e010857811d4f67a0751b20efdd9c1707aa3`; language `b65098321ecf79e4dc1182a7fab7bafeec0a238bb56153e81782f04fab838558`; source_project `1394370bcc5908873b830c2c11312e6e1611af41f46c4f43ca0a6bcb713bb466`; persistence `eda5e697adb29a8bf38b0d367099bd23267455e4792f97607e13dc0b232a3476`.
|
||||
|
||||
## Candidate comparison
|
||||
|
||||
| Candidate | Preservation and cost | Decision proposed here |
|
||||
| --- | --- | --- |
|
||||
| Source-save fail-closed guard only | Small immediate containment; an ordinary schema8 save would still fail because no public source8 reader exists. No low-noise analysis authoring. | Land only as a safety boundary within the repair; do not present it as completed persistence. |
|
||||
| Full externally tagged record/Expr notation using generic JSON-shaped fields | Could preserve the model after substantial changes for exact atoms, reference domains and maps. It exposes nested type tags and UUID-key machinery in every formula, and would require a second later text redesign. Generic field-name whitelists still cannot solve scoped keys. | Reject as the user-facing canonical language. The existing JSON API remains the machine interchange. |
|
||||
| Typed declarations and expressions lowering directly to existing model types | Adds one bounded grammar and typed editing spans, supports the accepted readable fixture, and avoids speculative evaluation/package architecture. Parser/writer/index/update must be delivered together. | Recommended smallest complete compatible source slice. |
|
||||
| Store formulas as strings or hide analysis in an opaque blob/sidecar | Smaller apparent parser work but breaks shared type/reference editing, traceable diagnostics, exact AST parity or single-model ownership. | Reject. |
|
||||
|
||||
The typed-adapter direction and semantics are already accepted; the exact concrete grammar below, canonical module names and resource implementation details are proposals for root/critic ratification.
|
||||
|
||||
## Concrete grammar contract
|
||||
|
||||
Keep existing project/declaration headers, aliases, explicit UUIDs, includes, descriptions and ordinary OA/SA grammar. Add neutral `quantity_kind`, `unit`, `calculation`; canonical layer words `oa_property`, `oa_check`, `oa_analysis` and their `sa_` counterparts map to EngineeringProperty, AnalysisCheck and AnalysisCase. **Only these short analysis declaration words are admitted.** The current long derived names (`oa_engineering_property`, `oa_analysis_check`, `oa_analysis_case` and SA counterparts) have never been admitted by this candidate's reader and must not be registered as speculative synonyms. Exclude those six derived entries from generic declaration-kind generation; formatter and tolerant outline use the short words consistently. These are source words, not renamed Kind variants or a new wire schema.
|
||||
|
||||
New analysis words/operators are contextual. Do not make previously legal architectural aliases globally invalid merely by adding expression keywords. Quoted names/descriptions and all existing descriptive expression/unit strings retain their previous meaning and bytes.
|
||||
|
||||
Required interpretation-bearing fields:
|
||||
|
||||
- Quantity kind: `dimension { time 1; }; algebra Linear;` or `algebra Affine(@difference_kind);`. Axis keys are the seven existing SI axes (`length`, `mass`, `time`, `electric_current`, `temperature`, `amount`, `luminous_intensity`); omitted axes mean zero, duplicate/unknown axes reject, authored values are bounded signed integers. Canonical output orders nonzero axes in DimensionAxis::ALL order; zero is `dimension {};`.
|
||||
- Unit: `kind @kind; scale <exact-atom>; offset <exact-atom>; symbol "ms";`. Scale/offset are Exact, never arbitrary Expr. The existing numeric/admission rules decide positivity, linear offset and affine compatibility.
|
||||
- Calculation: zero or more ordered `input name id "UUID" : <type>;`, required `result <type>; body <binding>;`. Parameters are a Vec: preserve authored order and stable ParameterId; reject duplicate names/IDs within the calculation. Zero inputs are valid. Parameter names are existing valid identifiers.
|
||||
- Property: required `subject @element_or_relation; type <type>; base <binding>;`; optional display unit is `display_unit none;` or `display_unit @unit;`. Resolve subject through the complete typed global table, yielding exactly Element or Relation; a definition/diagram/analysis element is not an inferred subject. Canonical output makes display_unit explicit; omission may mean None, never inventing an analysis type, subject or base.
|
||||
- Check: `subject none; documented_constraint none; predicate <binding>;`. The two optional links may be omitted with None meaning; the predicate is required even when `unbound`. Existing admission checks same-layer constraint/subject kinds.
|
||||
- Case: `subject none; base ModelBaseline; overrides { @property = <binding>; };`. Base and overrides field are required; `{}` is explicit empty. An absent map entry inherits; present `unbound` masks the base. `clear`, `none` or another Case is not a Binding/base value.
|
||||
- All three analysis elements are roots. Reject lexical nesting under another element and non-None parent. Neutral definitions have no layer/parent. Common description may be omitted as empty, as in existing declarations; no required semantic value comes from a factory default.
|
||||
|
||||
Types have exactly these forms: `Boolean`, `Number`, `Quantity(@kind)`, `List(Boolean)`, `List(Number)`, `List(Quantity(@kind))`. Nested List and invented primitive types reject. Constructor punctuation does not create additional model nodes.
|
||||
|
||||
Binding is `unbound` or one expression. Expression vocabulary covers every existing Expr, with no implicit casts/folding:
|
||||
|
||||
| Source form | Actual model node |
|
||||
| --- | --- |
|
||||
| `true`, `false`; unsigned decimal/integer/scientific atom; `ratio(-3, 4)` | Boolean; Number(Exact). Bounded numeric-only reconstruction follows the literal-trivia rules below, then calls Exact::parse_literal; never JSON Number/f64. |
|
||||
| `750 [@millisecond]` or `ratio(-3, 4) [@unit]` | Quantity with literal magnitude and Local unit. `(a + b) [@unit]` rejects; magnitude is not an Expr in the actual type. |
|
||||
| `@property`; `$parameter` | Property(global ID); Parameter(scoped ID). `$` is admitted only inside a calculation body and resolves against its owner. |
|
||||
| `[expr, expr]` | ordered List, including contextual empty list; no expression nesting shortcut for nested list types |
|
||||
| `-(expr)`, `abs(expr)`, `not(expr)` | Unary Negate/Absolute/Not |
|
||||
| `a + b`, `a - b`, `a * b`, `a / b`; `==`, `!=`, `<`, `<=`, `>`, `>=`; `and`, `or` | corresponding BinaryOp, with original left/right child topology |
|
||||
| `if(condition, when_true, when_false)` | If, all three children statically checked |
|
||||
| `@calculation(a: expr, b: expr)` | Call(Local definition, exact ParameterId argument map), names resolved in the **called** calculation, not the caller/global map |
|
||||
| `sum(values, initial)` | Sum with an explicit initial expression; no omitted-zero convention |
|
||||
| `power(base, -2)` | Power with signed integer exponent -16..16 under existing profile; no expression exponent |
|
||||
| `as_kind(@kind, expr)` | AsKind; existing nominal/anonymous type rule decides validity |
|
||||
|
||||
Precedence from low to high: `or`, `and`, equality, ordering, additive, multiplicative, unary, atom/call. Binary groups associate left; equality/ordering chaining without parentheses rejects rather than inventing chained-comparison semantics. Calls and comma-delimited constructs each require their exact arity. Parentheses may group but do not create Expr nodes. The canonical writer parenthesizes enough children to preserve the precise tree, including a right child of equal precedence; it does not use algebraic associativity to remove meaningful grouping. Canonical unary Negate always prints `-(...)`; Number(-1) prints `ratio(-1, 1)`. Thus `ratio(-1, 1)` and `-(1)` remain distinct through round trips, as required by the existing Exact contract.
|
||||
|
||||
Comparison non-chaining applies to both equality and ordering as one family: a comparison child on **either** side of a comparison parent is parenthesized even when ordinary precedence would otherwise suffice. Required emitted/accepted discriminators are `(1 < 2) == true` and `(true == false) == true`; `1 < 2 == true` and `true == false == true` reject. Numeric associative structure is separate: `1 - (2 - 3)` preserves the right-nested tree; `(1 - 2) - 3` may canonically print `1 - 2 - 3` because the parser rebuilds that exact left-nested tree. The writer must not flatten `1 + (2 + 3)` to a different tree merely because arithmetic values would agree.
|
||||
|
||||
### Literal trivia and reconstruction
|
||||
|
||||
Grammar uses significant tokens, retaining every token's original byte span. Existing whitespace and `//` comments may occur between constructor tokens and before a quantity suffix. Thus `ratio (1, 2)`, `ratio(1, // rationale\n 2)` and `750 // measurement\n [@millisecond]` are valid. A decimal/scientific atom is one indivisible token: `1e-2` is one atom; `1 e - 2`, `1e // note\n -2`, `1 . 2`, `1_0` and `0x1` are not alternative atom spellings. Adjacent tokens without an operator must not be concatenated into an atom.
|
||||
|
||||
For ratio, require exactly `ratio`, `(`, optional numerator `-`, one unsigned integer token, `,`, one unsigned denominator integer token, `)`, ignoring trivia at token boundaries. The numerator sign may be separated from its integer by trivia; `+` is not admitted. Components cannot be decimal/scientific expressions or signed denominators. Validate component digit counts and total normalized-byte size **before allocating** a reconstruction buffer. Copy only the verified sign/digit token bytes into bounded `ratio(-n, d)` spelling, then pass that spelling to the existing Exact::parse_literal with the shared literal WorkMeter. No identifiers, comments, unit references, operators or arbitrary source fragments enter the numeric reconstruction. Exact performs the same existing numeric validity/reduction checks.
|
||||
|
||||
For unsigned decimal/scientific atoms pass the single original numeric token directly; its internal spelling is never reconstructed from multiple tokens. The same ratio/atom rule applies to scale, offset and Quantity magnitude. Retain the full original literal span plus punctuation/component spans for editing and diagnostics: the reconstruction is ephemeral numeric input, **not a source rewrite**. Alias-only changes to `750 // measurement\n [@millisecond]` change only `millisecond`; parameter/property/definition renames leave ratio comments and original decimal spelling byte-identical. When an actual literal value changes, preserve its internal comments as local enclosing annotations under existing SyntaxUpdate policy.
|
||||
|
||||
Every raw byte, including trivia, is charged by the source scan budget; every lexical token and ratio component is charged before syntax allocation; reconstructed numeric work is charged only once by the shared numeric meter. `MAX_LITERAL_BYTES` limits the bounded numeric spelling, not an arbitrary amount of comments: original trivia remains independently bounded by whole-source bytes/tokens/scan work. A huge ratio comment therefore cannot cause an unbounded reconstruction buffer. Test accepted and rejected trivia at parse, canonical roundtrip and alias-only update boundaries.
|
||||
|
||||
## Concrete valid example under this proposal
|
||||
|
||||
This is proposed source8, **not a claim that the current parser accepts it**. It uses the same17 UUIDs and typed bindings as `analysis/tests.rs:101`, **with explicit display-name changes**. The existing fixture helper sets each non-project name equal to its alias, so its Project cannot be used unchanged as this example's equality oracle. The canonical sparse-dimension and override block terminators above remove ambiguity in the earlier illustrative fixture. Descriptions may be empty/omitted.
|
||||
|
||||
```text
|
||||
arcadia 8;
|
||||
project response_budget "Response budget" id "4210b9f2-7046-47f0-aa3b-153f626a579d" {
|
||||
quantity_kind duration "Duration" id "188b911c-5f8b-431c-9eea-2d41f3953767" {
|
||||
dimension { time 1; }; algebra Linear;
|
||||
}
|
||||
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";
|
||||
}
|
||||
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;
|
||||
}
|
||||
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" {}
|
||||
}
|
||||
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; };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The expected Rust Project builder must independently assign these **14** display names, keyed by the already specified UUID/alias; this table is part of the oracle, not derived by parsing the example:
|
||||
|
||||
| Record alias | Required name |
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
|
||||
Project name remains `Response budget`; project and all record descriptions in this worked example are empty. Parameter names remain `a`/`b` and are not global display-name rows. Build these exact headers plus the independent existing typed bodies and IDs; never ignore/normalize names or obtain the expected Project via the new parser/formatter. Assert these name fields explicitly before the first parse comparison so accidentally reusing fixture() unchanged fails clearly.
|
||||
|
||||
An SA twin changes only the architectural bodies/layers and declaration words, with fresh global IDs; definitions remain neutral. Validate exact typed equality against a separately constructed actual Project, not against data produced by the source parser/writer. Baseline 7/4 and degraded 9/4 are the existing future evaluation expectations; this persistence slice verifies bindings/graphs and cannot claim it has evaluated those results.
|
||||
|
||||
Concrete invalid mutations, each rejected without replacing the accepted model/source/files:
|
||||
|
||||
- `arcadia 7;` with any of these six declaration kinds, including empty bodies, or any explicit definition-catalog attempt. Older input must not quietly acquire/discard new fields.
|
||||
- Remove a property's subject/type/base, calculation result/body or case base/overrides; use `base none`, `base @degraded` or `overrides { @receive_time = clear; };`.
|
||||
- Duplicate `a:` call arguments or override `@receive_time` entries; duplicate parameter ID in one calculation; use `a:` plus another name resolving to the same parameter. Reject before BTreeMap insertion can overwrite data.
|
||||
- `@combine(a: @receive_time)`; extra `c:`; `$a` outside the calculation; a calculation body reading `@receive_time`; a Quantity type referring to `@second` rather than a kind; a quantity suffix referring to `@duration` rather than a unit.
|
||||
- `ratio(1, 0)`, `power(2, 17)`, unknown dimension axis, dimension exponent33, a negative scale, a linear unit with nonzero offset, or nested `List(List(Number))`.
|
||||
- `if(true, 1, @duration)`, a same-layer property cycle only introduced by one stored case, or OA property reading an SA property. Unused branches do not exempt static admission.
|
||||
- Moving a property lexically inside `respond`; a definition placed under an activity; a subject naming an analysis case. No containment inference repairs these.
|
||||
- Global reidentity map from a definition to a node-placement record; orphaned argument/override target after deletion without explicit same-batch repair.
|
||||
|
||||
## Source pipeline and exact editing
|
||||
|
||||
1. Extend `language::DeclKind` and declaration parsing with a typed analysis payload branch. Keep raw numeric spans and unresolved typed reference spans while parsing. Do not first resolve analysis fields through the generic Ast::resolve/string-key whitelist. Construct required ElementBody::shared_in records and Definition directly, then run existing complete Project admission. Keep old declaration parsing untouched outside the necessary lexical punctuation support.
|
||||
2. Resolve in bounded passes: collect all global declaration IDs/aliases and all calculation headers/parameter vectors across reachable files; then resolve definition types and analysis bodies/call argument names using those tables. Forward references, forward calls and cross-file definitions work independent of declaration order. Pure-calculation/body scope is an explicit owner, never a global parameter registry.
|
||||
3. One typed source representation supplies strict lowering, canonical writing, EditValue/path extraction and source diagnostics. It may retain bounded temporary syntax nodes/spans, but cannot become a second persistent model/evaluator. Include declaration/header/field spans, ordered parameter identity/name spans, override target spans, called-definition/argument-key spans and every Expr child path. Preserve existing top-level SourceIndex consumers; extend with typed locations rather than changing their key meanings.
|
||||
4. Writer walks typed definitions/analysis bodies directly, including Exact::canonical_literal. It does not route those bodies through serde_json::to_value/payload/unwrap. Canonical calls print named arguments in the declared parameter order; parameter vectors retain their own order; override entries sort by stable target identity. Definitions get an explicit record category distinct from node placements in source identity correspondence.
|
||||
5. In `SyntaxUpdate`, match parameter declarations by `(calculation ID, ParameterId)`, overrides by property ID within their case, and expression children by typed owner/field/path. A call argument occurrence is keyed by **`(owning declaration ID, binding field including override target if any, Expr call-site path, called calculation ID, ParameterId)`**; the callee/parameter pair alone is insufficient. An alias-only edit rewrites only the relevant alias token, including inside a call, unit/type suffix and override key. Parameter rename edits its name, `$` uses and argument labels of every call site to that calculation only. Global Reidentify uses existing typed analysis reference visitors, never rewrites a ParameterId just because its UUID equals a global ID, and never treats old historical capsules/strings as live refs.
|
||||
6. Retain unchanged authored expression bytes, including decimal/scientific spelling, whitespace and comments. For changed subtrees replace only the smallest changed syntax; carry or retain comments from removed nodes as enclosing annotations following existing source-update conventions. Whole-field regeneration on every alias rename is insufficient. Avoid canonicalizing unrelated parameter order, includes, line endings or modules during one edit.
|
||||
7. `format_bundle` gives definitions a deliberate neutral default file (proposed `definitions.arcadia`), and new layer-root analysis records a category file (proposed `oa/analysis.arcadia`, `sa/analysis.arcadia`). These are defaults only. Existing authored ownership stays fixed; alias/parameter rename never relocates files. No subject-derived ownership or implicit nested property is introduced. Keep existing file-count fallback/path/case/include validation.
|
||||
8. Continue to recompile exact updated bytes, reject stale/forged indexes and require whole Project equality before source acceptance. Session/PreparedProject/Document history naturally retains the new bytes/records only if this boundary remains intact. Retain new navigation/rename consumers' exact declaration recognition and token ownership during later root composition; tolerant highlighting must recognize new punctuation without diagnosing every incomplete keystroke as a persisted-model admission.
|
||||
|
||||
Missing UUIDs may use the existing explicit stabilization workflow, including parameter headers, but the first compile must insert the assigned ID into the owning document and rebuild exact indexes. Never independently assign a fresh parameter UUID at each later compile. Canonical source always contains IDs. Reuse of one ParameterId in two different calculations is valid; all update/index keys must include its owning calculation.
|
||||
|
||||
Mandatory occurrence discriminator: `@combine(a: @receive_time, b: @dispatch_time) + @combine(a: @receive_time, b: @dispatch_time)` has four distinct argument-key spans, despite only two callee/ParameterId pairs. Rename parameter `a` to `first` and verify both `a:` tokens and all `$a` uses change while the two call-site comments, `b:` tokens and unrelated same-name parameters remain exact. Also swap `a`/`b` names in one complete EditDefinition with stable IDs: build all edits against original spans before applying, so a sequential string replacement cannot collapse the names or change argument associations. Canonical ordering may follow the current parameter Vec, but alias-only updates must not reorder authored call arguments.
|
||||
|
||||
The shared lexer is a necessary upstream change, not an optional highlighter follow-up. At `language.rs:199–224`, current numeric token classification already invokes serde_json::Number, greedily absorbs `+`/`-`, and classifies `$`, arithmetic/comparison operators as Error; strict_tokens then rejects before a typed adapter could run. Introduce bounded raw decimal/scientific lexing without numeric conversion, separate operators with longest-match handling (`<=`, `>=`, `==`, `!=`), `$`, and correct exponent-sign handling. `1-2`, `1e-2`, `ratio(-1, 2)` and `-(1)` must tokenize distinctly. Existing generic numeric fields still use their original finite JSON/i64/f64 admission at consumption, including signed coordinates; lexing must not expand that field grammar to arbitrary Exact values or silently accept malformed `1e`, `1_0`, `0x1` or previously rejected `+1`. Preserve comments/string escaping and byte-span/Unicode diagnostics. Run old coordinate/message-value/nesting token tests alongside the new exact atoms. A source-mode switch that tokenizes the same included declaration differently from its entry is unsuitable.
|
||||
|
||||
## Resource and older-version contracts
|
||||
|
||||
Keep existing whole-file/bundle byte, include, graph-object, nested-record and ordered-reference limits. Count definition records, parameter declarations, all stored Expr nodes, call arguments, override entries and lists across the complete reachable bundle before allocation or expensive resolution can multiply work. Do not reset counters per module, field, case or call. Existing `nested_record_count` is a final model check, not sufficient preallocation admission.
|
||||
|
||||
Use a bounded iterative typed-expression parser/writer/edit visitor or an explicitly bounded stack strategy with adverse-input evidence. Logical depth counts Expr edges; source parentheses/call punctuation need a separate syntactic-depth/node budget so millions of redundant groups cannot hide below Expr depth1. Existing generic value depth64 remains for existing records; it must not silently reduce typed Expr128 or be globally removed. Pair depth128 success with129 rejection and flat wide lists/call maps and partial-input cleanup. Resource rejection must leave no partial Project or accepted source.
|
||||
|
||||
Exact atoms call the existing metered parser after raw-span/digit checks. Unit scale/offset and every binding share one aggregate literal-admission meter across the entire parse, distinct from evaluation fuel and static graph events. A fresh WorkMeter per literal is not aggregate protection. The concrete candidate limits and calibration below replace r1's unspecified ceiling; they are proposed, not measured or adopted. Do not claim private JSON Scope node metering provides this source literal meter.
|
||||
|
||||
### Proposed source8 admission profile and calibration
|
||||
|
||||
Use one private `Source8Budget` for a **complete reachable bundle parse**, passed explicitly through every file/declaration/body. Public ParseLimits retains its existing four fields; no public struct-field/API break is needed for this slice. Production entry points derive the hard source8 profile below, and a crate-private test entry point may lower every ceiling. Existing caller byte/graph/record/reference restrictions remain independent and can only lower acceptance. `UNLIMITED` does not disable hard source8 limits.
|
||||
|
||||
| Counter | Candidate ceiling | Charge point / connection to caller policy |
|
||||
| --- | --- | --- |
|
||||
| Raw UTF-8 bytes | `B = min(max_source_bytes, 64 * 1024 * 1024)` across reachable files | Sum checked before token Vec or decoded-string allocation; include comments/trivia. Preserve existing ProjectLimits file/include restrictions. This hard cap applies to source8 candidates; do not accidentally redefine older standalone-source transport policy. |
|
||||
| Lexical tokens | `min(B, 1_048_576)` including whitespace/comment tokens | Reserve before every token push; zero-byte EOF marker is excluded. A single long comment is byte-bound even though one token. Failure stops tokenization immediately. |
|
||||
| Analysis syntax entries | `min(262_144, max_records)` | Reserve before each Expr node, parameter row, call argument entry, override entry, definition body, analysis body or type constructor allocation. Count duplicate attempted entries before rejecting; never insert then discover budget overflow. This bounds temporary syntax too, not just final accepted model records. |
|
||||
| Additional global records | Existing max_graph_objects/max_records | Definition headers are graph records; all old graph/record checks remain. Existing final check_model still verifies the completed model. Temporary syntax entries are a separate counter, not a renamed/double-interpreted final record count. |
|
||||
| Definition/argument/reference occurrences | `min(500_000, max_step_references)` | Count each source analysis reference occurrence before resolution: type/kind/unit/property/subject/constraint refs, parameter uses, call target and each argument/override target. Combined charge also retains all ordinary ordered-step references, so files cannot each reset the transport reference budget. |
|
||||
| Active typed syntax frames | 256 | Parentheses, lists, function/call/type constructors and ratio parsing enter explicit frames; reject before pushing frame257. Logical Expr depth128 remains separate. No native-stack recursion is justified by this counter alone. |
|
||||
| Exact numeric work | 50_000_000 numeric units per complete parse | A single WorkMeter constructed with `WorkLimits::new(0, 50_000_000)` feeds all Exact::parse_literal calls. No evaluation visits are made. This is the existing maximum numeric reference ceiling used as a **candidate admission ceiling**, not an evaluated-work claim. Caller byte/record/reference caps do not scale this number; they independently reduce the possible workload. The private test profile may lower it. |
|
||||
| Static analysis work | Existing AdmissionLimits::REFERENCE, one million events | Run the actual admission once after construction, with its own recorded usage; no per-case reset. This proposal does not alter its charging rules. |
|
||||
|
||||
Per-atom limits remain the existing 1234 component digits, 2500 normalized literal bytes, 4096-bit components and decimal exponent -128..128. Source8 scope must be known before broad token allocation: the entry's bounded header scan decides the version; included fragments inherit that one checked version and budget. Fragments must not select different resource policy by local spelling. Source3–7 migration to source8 must validate its produced candidate under the same source8 limits before acceptance/save, while preserving the previous accepted model on limit failure.
|
||||
|
||||
Parsing must remain linear in scanned raw bytes/tokens plus explicitly metered resolution/numeric work: one raw lexical scan, bounded lookahead, no repeated scan of a whole expression per descendant. Parser, resolution and writer traverse explicit worklists and preserve first-failure location. One compile may perform its existing bounded stabilization/migration reparse; each entire candidate parse gets a fresh budget, never each field/file. Instrument the compile pipeline to establish its fixed pass count (initial, stabilized and migrated candidate; expected at most three), and reject any design that recursively reparses until success. Updates may independently check old/new whole candidates; record those passes separately so measured totals do not hide them.
|
||||
|
||||
Numbers above are deliberately falsifiable candidates. They must not silently become advertised production limits merely because parser tests pass. Proposed Windows calibration before enabling source8:
|
||||
|
||||
1. Build an isolated actual-core test/probe executable after the lexer/typed adapter exists, in debug and optimized profiles. No compiler is authorized by this report. Invoke the same public compile/format/update/decode/save routes; a parser-only allocation microbenchmark is insufficient. Record exact commit, lock/toolchain, command, input SHA, effective counters, first failing byte/path, elapsed time, process peak working set and private bytes, exit and stderr. Use an external Windows process sampler; include process baseline and absolute peak, rather than subtracting shared pages to invent an allocator measurement.
|
||||
2. Deterministic workload families: (a) valid17-UUID model with exact display names; (b) near-limit legal flat Expr/list/call/override inputs split over 1/8/64 includes; (c) many near4096-bit coprime ratio components and consecutive-Fibonacci numerator/denominator pairs to stress reduction, with equal-value trivial integer controls; (d) 256/257 nested redundant groups around a depth1 Number plus logical128/129 valid typed trees; (e) token-limit comments/whitespace and raw-byte-limit long comments; (f) repeated duplicate argument/override floods, unknown refs and truncation at every structural boundary of a wide/deep input. Keep generated numbers within exact-digit/bit limits before blaming aggregate work. Each failed case also measures Drop/cleanup and verifies a fresh subsequent parse succeeds.
|
||||
3. Use geometric sizes around the proposed ceilings (1/4, 1/2, 1, then one entry beyond) with three fresh-process repetitions. For numeric exhaustion, choose a lowered test ceiling equal to the successfully measured deterministic cost of a specific prefix, then a prefix-plus-one atom; assert no later literal/resolution visit. Split identical declaration streams across includes and verify the same aggregate numeric/syntax usage and failure identity (path changes are expected). Order-dependent first failures must follow documented include/declaration order, never hash iteration.
|
||||
4. Functional acceptance: the worked model and a **valid near-ceiling counterpart for each independent bound** succeed, exact bound+1 rejects before the offending allocation, no panic/stack overflow/hang, exact whole Project and untouched-source/disk sentinels hold, recorded counters cannot exceed their ceilings, and repeated runs have identical logical usage. A benchmark where every large case is rejected by an unrelated lower limit does not validate the intended bound; lower other dimensions or isolate the private budget test to reach it.
|
||||
5. Proposed desktop resource targets for root ratification: worked fixture compile/format/update each at most100ms optimized median; any one bounded compile/admission attempt including failure cleanup at most2s optimized and10s debug; peak private bytes at most512MiB and peak working set at most768MiB for the probe process. Treat these as **candidate usability ceilings**, not observations. Record slower/ larger results; adjust one candidate bound at a time and rerun affected near-limit successful/failing controls. Do not merely loosen a threshold to relabel failure, reduce Expr128 or hide the elapsed reparse passes. If large valid models need more capacity, root must compare measured responsiveness/memory tradeoffs before accepting a higher profile.
|
||||
|
||||
This calibration is a stop/go decision for source8 exposure. JSON optimized RSS, its depth enable switch and complete native/API parity remain separate gates even if these source measurements pass.
|
||||
|
||||
JSON8 public depth remains governed by `DEPTH_128_VALIDATED == false`. This source repair must not toggle it or treat successful shallow fixtures as deep transport acceptance. Retain the public-codec fail-closed behavior; record any resulting deep source/JSON parity limit explicitly until the separate codec gates close. Static Expr128 is an accepted semantic contract, not evidence that every present transport already fulfills it.
|
||||
|
||||
Admit source8 only with complete typed support. For source3–7, preserve each existing field/constructor rejection and migration route, adapting an empty definition catalog and changing required version/default tokens only. Old schema5/6/7 JSON continues through the already implemented Project7 DTO, with explicit unknown/new-field rejection even for empty/null values. Do not add serde defaults to new-schema required fields. Existing source1/2 legacy paths remain explicit; do not rename their syntax or reinterpret descriptive expressions, DataField unit strings, message values or semantic baselines. Freeze ordinary OA/SA sources with comments/Unicode/CRLF and exact old JSON fixtures as migration controls.
|
||||
|
||||
In particular, source3 lowering at `language.rs:1836` currently constructs a legacy payload by serializing the current Project before invoking the old adapter. Audit this exact boundary against the frozen old envelope: newly present `definitions` must not leak into a closed old payload or be used to make new source3 fields acceptable. Construct the intended old envelope explicitly where necessary, preserving the existing migration's IDs and semantic defaults. Do not infer older-source acceptance solely from Project7 JSON tests.
|
||||
|
||||
## Checked-save before replace
|
||||
|
||||
After semantic/model-limit admission and bounded formatting of a `.arcadia` output, but **before creating/replacing destination content**, decode the exact generated bytes through the public source-capable limited decoder with the same effective document limits. Require decoded Project == requested Project and no unexpected migration. Return an ordinary located/typed failure on decode or mismatch. Do not compare only IDs, counts, selected analysis fields or a normalized analysis-free projection.
|
||||
|
||||
Only after that check succeeds: write/sync temp bytes, perform the existing expected-original-byte check and persist using the existing filesystem mechanism. Keep its conflict semantics; this proposal does not claim to eliminate all external writer races. Newline addition must occur before the decode/equality check so the checked bytes are precisely the returned/persisted bytes. Ensure all public save wrappers reach the guard. Leave bounded JSON codec/profile mechanics under root ownership; do not widen admission as a workaround for the source failure.
|
||||
|
||||
The modular source_files::save_project route keeps its compile-and-compare gate over the full exact bundle and existing transaction/conflict machinery. It must include definitions/analysis now that compile_bundle understands them. It must not flatten includes into the checked-save single-file API. Failures leave existing file bytes and file inventory intact; a no-destination save failure must not create the destination.
|
||||
|
||||
## Meaningful acceptance sequence, all proposed/unrun
|
||||
|
||||
1. Add `crates/oa-core/tests/analysis_source.rs` using the real17-UUID typed bodies independently constructed as in `analysis/tests.rs:101` **and the explicit14 display-name assignments above**; do not reuse unchanged fixture() or use formatter output as the sole expected input. Parse the explicit example, compare whole Project, format/parse again, compare whole Project and canonical text stability. Split the source into entry/neutral/OA-or-SA modules with forward includes, Unicode/CRLF/comments and equal string occurrences of aliases. Compile twice and compare exact bundles/indexes; test missing-ID stabilization separately. Retain an unused definition and empty/unbound calculation to catch accidental reachable-only export.
|
||||
2. Exercise all Expr/AnalysisType/DefinitionBody variants with independently built valid typed projects: signed ratio versus Unary Negate, equal-precedence grouping, List/If/Sum/Power/AsKind, affine/difference kind, unit offset, scalar Boolean/Number and quantity/list calculations. Pair invalid examples above with exact located rejection and no mutation. Source persistence, not numeric evaluation, is the oracle for this gate.
|
||||
3. Run source update transactions: change one property base, SetAnalysisOverride(Unbound), ClearAnalysisOverride and undo/redo; rename property/definition aliases; rename a parameter through EditDefinition while preserving its ID; reidentify a global definition whose UUID intentionally equals a scoped ParameterId; reorder parameters; delete a used definition with and without same-batch repair. Compare accepted whole Project to the independently applied core command, exact targeted source token changes, unrelated file bytes/comments and history. Case-only cycle rejection retains previous source/revision/history. Introduce two calculations reusing a ParameterId/name to falsify unqualified parameter rewrites.
|
||||
4. Add safety tests at the save preparation boundary with deliberately unparseable and parseable-but-incomplete generated source: both must fail before replacement and retain existing sentinel bytes. Keep the seam private and exercise the same guard the real writer calls. Then execute the **unchanged** existing `checked_save_returns_exact_bytes_and_accepts_unchanged_external_content` against ordinary and populated projects for `.arcadia` and `.oa.json`, fresh/existing/unchanged external/different external cases, exact returned bytes and public reopened Project. Do not weaken the existing test or claim the prior failure vanished from evidence.
|
||||
5. Reuse `source_project.rs` tests `model_updates_rewrite_cross_file_alias_uses_preserve_ownership_and_untouched_comments`, `alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at`, `failed_update_preserves_candidate_and_rejects_stale_index`, `reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity`, `project_admission_is_aggregate_and_precedes_global_resolution`, and existing source_files transactional-save/reopen tests. Extend representative fixtures; retain the original controls.
|
||||
6. Reuse `analysis::tests::complete_real_model_roundtrips_and_exposes_explicit_effective_bindings`, `narrow_override_commands_preserve_missing_clear_and_history`, `global_reidentify_rewrites_typed_domains_but_preserves_scoped_parameters`, `calculations_are_pure_parameters_are_scoped_and_calls_bind_exactly`, `frozen_schema7_upgrades_without_interpreting_descriptive_metadata`, existing source migrations/nesting/review regressions and JSON codec tests. Add aggregate-source and half-parsed wide/deep failures; do not infer resource safety from shallow happy paths.
|
||||
|
||||
First bounded executable gate after root adopts and independently reviews the implementation:
|
||||
|
||||
```text
|
||||
cargo test --locked -p oa-core --test analysis_source -- --test-threads=1 --nocapture
|
||||
cargo test --locked -p oa-core --test review_regressions checked_save_returns_exact_bytes_and_accepts_unchanged_external_content -- --exact --test-threads=1 --nocapture
|
||||
```
|
||||
|
||||
Run in a newly identified isolated target under the sole compiler lease, preserve failed inputs/logs before correction, then run affected source/migration/analysis/API persistence gates. Success means populated whole-model/source parity and safe replacement, not merely accepting `arcadia 8;`. Public API stored SourceBundle/model equality, older-version HTTP/CLI save/reopen and optimized codec memory remain separate retained obligations; Linux remains deferred.
|
||||
|
||||
## Ownership and decisions still requiring critique
|
||||
|
||||
Use new `language/analysis_source.rs` plus a small typed-source update module if needed; shared hooks are `language.rs`, `source_project.rs`, source_structure/source_migration and the checked-save guard in persistence. No model schema/type rewrite is needed. Root must partition those shared files against the Scenario payload peer. Compose typed Diagram payload handling independently, then make **one** final schema8/version/migration acceptance change; include an ordinary architecture diagram plus a populated analysis project plus Scenario-layout payload in a later combined round trip. Analysis source must not interpret Scenario view coordinates or grant gesture feasibility from finite-number parsing. Comment785 is the existing ownership agreement; no new Gitea claim/post was made here.
|
||||
|
||||
Root-selected r2 corrections are fixed: short words only, significant-token numeric trivia with bounded reconstruction, exact display-name oracle, call-site-qualified spans and comparison-child parentheses. Remaining adoption decisions: the proposed resource table and measured thresholds; exact private index representation/missing-parameter-ID insertion mechanics; and default neutral/analysis module filenames. The central semantics, schema8 allocation, no imported placeholders, precise reference domains and no header-only repair are already accepted constraints.
|
||||
|
||||
### Staged implementation partition before coding
|
||||
|
||||
No overlapping source writers. The root integration owner owns manifest/lib hooks, shared codec/version/migration policy, compiler lease and composition with Scenario work under comment785. Suggested bounded stages:
|
||||
|
||||
| Stage | Writer/files and explicit boundary | Review/exit criterion |
|
||||
| --- | --- | --- |
|
||||
| S0: checked-save containment | One writer: persistence.rs plus a private source-save guard test module; no JSON codec, language header/grammar, model or schema edits | Exact final-newline source bytes publicly decode under the same effective limits, no migration, whole Project equality before temp/destination write. Malformed and parseable-lossy private-seam inputs preserve existing/missing destination sentinels. Existing public regression remains unchanged and still fails until the parser is repaired. Root has now authorized this containment invariant independently. |
|
||||
| S1: typed lexical/syntax contract and budget | One source-language writer: new language/analysis_source.rs and its lexical/parser tests, bounded source budget; only necessary shared language.rs tokenizer/parser hooks reserved to that writer | Strict/ tolerant tokens and existing numeric-coordinate controls; all Expr shapes/ratio trivia/duplicate keys; short declarations only; lowerable budget boundaries and cleanup. Do not enable the public schema8 header as an isolated patch. Keep modules private/source-only until S2/S3 compose. |
|
||||
| S2: typed resolution and canonical writer | Same language owner or explicit sequential handoff: analysis_source.rs lowering/writing, language.rs DeclKind/format_declarations/required constructors; independent analysis_source.rs expected-model tests | Complete worked OA and SA model, definitions/all Exprs, exact fourteen names, independent whole Project/canonical roundtrips. This is the first point at which public source8 parser admission may be composed, together with S3 so editing/checked save are complete; still no release adoption. |
|
||||
| S3: typed source indexes and updates | Source-update owner after S1 typed spans freeze: source_project.rs plus new source_project/analysis_source_update.rs, limited source_structure/default ownership hooks; no simultaneous shared language.rs edits | Qualified parameter/call-site/override matching, swaps/repeated calls, missing-ID stabilization, original-module/comment preservation, identity-map categories and whole-model command/update equality. Coordinate temporary language hook requests through its owner. |
|
||||
| S4: final migrations and guarded save composition | Root: final source header version admission and older migration hooks, previously frozen JSON DTO compatibility, source_files full-bundle save and S0 guard integration; Scenario peer supplies only its owned typed Diagram/source-row delta | Ordinary old OA/SA migrations with exact comments, populated analysis8 plus Scenario payload composition, no header-only acceptance, all checked save destinations/reopen/history/ conflict sentinels. No schema bump beyond8 or codec switch change. |
|
||||
| S5: execution and adoption | Root sole compiler owner; independent critic on immutable complete source pin | Focused source8/S0 tests then affected source/analysis/migration/full/strict gates, measured profile calibration above, explicit remaining deep-JSON/native/API limitations. Failed gates retain exact inputs/logs before correction. |
|
||||
|
||||
Stages are implementation leases/checkpoints, not independent feature releases. S0 can safely contain failure by refusing source save, but does not satisfy the user's persistence objective. S1–S4 must form one coherent reviewed source8 slice before enabling it in the shared application. Root should assign S0 first, then freeze typed syntax/index contracts for S1/S3; speculative evaluator/API expansion is outside every stage.
|
||||
|
||||
The most useful next action is independent critique of this complete source boundary, especially parameter identity versus source trivia, AST-preserving pretty printing and limit accounting, followed by one owned implementation slice. Further evaluator architecture research would not repair the demonstrated persistence failure.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Source8 S0/S1 source handoff
|
||||
|
||||
2026-09-13, Codex-956atf /root/nested_call_design. Source-only work in `.local/analysis-core-worktree-20260911`, branch `codex/analysis-core-20260911`. The worktree is clean at **02b9d5ce4c73d0446146b13b46a228e6fd4d7735**. No compiler, Cargo, test executable, native UI or Gitea write was run by this worker. Root owns publication, integration and compiler leases. Latest fetched bff8014 AGENTS instructions were read; its documentation commits were not merged and the user-owned specification was untouched.
|
||||
|
||||
## Pins and design
|
||||
|
||||
- Original input: `847b1caa7e75bb82f6ce5193c45bcfc45973ad1a`.
|
||||
- S0 containment: `112e0d910bd2b2c9a191f29536fecb0fbc0177b0`, two files, 187 insertions/4 deletions.
|
||||
- S1 staged syntax: `02b9d5ce4c73d0446146b13b46a228e6fd4d7735`, six files, 1345 insertions, directly atop S0.
|
||||
- Revised design: `analysis-source8-repair-design-r2-20260913.md`, SHA256 `3a5b96fd130e3859932ee25544b3ff2f6a21f0774f3b5849e546f9574b9dbf7d`. Original db2e266 design preserved. Root accepted the corrected syntax/oracle direction, candidate resource table for experiments and reversible default module names; measurements/profile adoption remain open.
|
||||
|
||||
## S0
|
||||
|
||||
The generated `.arcadia` bytes receive their final newline, then pass the public limited decoder with the same effective profile byte limit. Whole Project inequality and unexpected migration reject before NamedTempFile creation. The source-format function is injectable only through a private seam. Existing JSON encode, expected-original conflict and persistence code retain their behavior; no source8 header whitelist or JSON/depth change was added.
|
||||
|
||||
Four tests under `persistence::source_save_tests::`, all unrun:
|
||||
|
||||
- `malformed_generated_source_rejects_before_any_filesystem_staging`
|
||||
- `parseable_source_omitting_a_definition_cannot_replace_the_whole_project`
|
||||
- `equal_project_from_generated_legacy_source_still_rejects_migration`
|
||||
- `generated_source_admission_checks_final_newline_under_the_effective_byte_limit`
|
||||
|
||||
Each checks existing/new/missing-parent destinations. Missing parent distinguishes decode-before-temp from a later temp creation that would fail with Io. The independently parsed legacy source equals the ordinary Project for the migration control; the lossy control adds an actual unused Duration definition while preserving ordinary headers/records. The original public checked-save regression is unchanged and is expected to remain failing at save until full source8 support exists. Containment is not persistence completion.
|
||||
|
||||
## S1
|
||||
|
||||
`language.rs` adds only a four-line **cfg(test)** module declaration. The old shared tokenizer, source3–7 parser and public source8 rejection are byte-identical in their implementations. The typed raw lexer is private and test-staged until the S2 consumer can integrate signed ordinary numeric consumption compatibly. This avoids exposing an unfinished global lexer/reader combination.
|
||||
|
||||
New `language/analysis_source` modules supply unresolved typed Binding/Expr syntax and exact source spans. They cover all13 existing Expr shapes, named arguments, scoped-name syntax, required constructor arity, literal-only quantities, explicit Sum initial, bounded Power, precedence and non-chaining comparisons. They do not resolve aliases/ParameterIds, validate purity/types or produce a Project. The parser uses an explicit task/value stack, with syntactic frame and logical AST depth checks. Accepted partial ASTs have depth at most128; ordinary owned-tree teardown remains bounded by that depth.
|
||||
|
||||
Raw numeric lexing never invokes serde_json::Number. Significant ratio tokens are checked and reconstructed into a bounded numeric-only spelling, passed to the existing metered Exact parser; full raw literal span and individual punctuation/component spans survive. Call argument occurrences retain unique spans even when their callee/parameter names repeat. Actual qualified SourceIndex entries and token-preserving command updates belong to S3; the span replacement test is a syntax discriminator, not a claim that production rename works.
|
||||
|
||||
An explicitly shared Budget aggregates bytes, lexical tokens (including skipped trivia), syntax entries, references and exact numeric units across fragment parse calls, with bounded active frames. Reservations precede token/AST/reference allocation, duplicate attempted call entries are charged, and resource exhaustion is latched. Candidate ceilings derive from the r2 table and existing lowerable ParseLimits; no measured resource adoption is claimed. Full declaration/graph/include/reference accounting and entry-version selection remain S2/integration work; expression-only budget tests cannot prove whole-bundle admission or optimized RSS.
|
||||
|
||||
Nine tests under `language::analysis_source::tests::`, all unrun:
|
||||
|
||||
- `significant_ratio_and_quantity_trivia_preserve_original_spans`
|
||||
- `raw_numeric_lexing_preserves_exact_atoms_and_operator_boundaries`
|
||||
- `unary_literals_precedence_and_comparison_children_keep_distinct_trees`
|
||||
- `every_expression_shape_and_exact_constructor_arity_is_represented`
|
||||
- `repeated_call_argument_occurrences_have_distinct_original_spans`
|
||||
- `budgets_aggregate_across_fragments_and_latch_numeric_exhaustion`
|
||||
- `byte_token_frame_logical_and_list_boundaries_have_nearby_successes`
|
||||
- `partial_wide_and_deep_input_cleanup_does_not_poison_a_fresh_parse`
|
||||
- `public_source_headers_and_existing_numeric_token_controls_are_unchanged`
|
||||
|
||||
They cover exact negative Number versus Unary, comparison left/right grouped children, 400-digit atoms, comments inside ratios and before units, repeated call keys, malformed arity/components, per-budget near successes/failures, numeric exhaustion across fragments and cleanup. They do not establish canonical source writing, source-edit preservation, S2/S3 functionality or CPU/memory timing.
|
||||
|
||||
## Performed and remaining checks
|
||||
|
||||
Rustfmt edition2024/skip_children was run on the two S0 Rust paths and six S1 Rust paths using the configured local toolchain; all invocations exited0. Git whitespace checks passed. No execution result is claimed.
|
||||
|
||||
Next: independent immutable-source review of S1 and root-assigned compiler gate selecting both test prefixes, retaining the unchanged public regression as the known incomplete-source8 witness. Fix findings in a new commit without changing old evidence. Then proceed to S2/S3 under explicit ownership, and root's final migration/version/resource acceptance. No merge of remote documentation history or force-push was attempted here.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Independent S1 immutable-source review
|
||||
|
||||
2026-09-13. Reviewer: Codex-956atf `/root/source8_syntax_review`, GPT-6 Astra. Parent owns synthesis, integration, compiler leases and Gitea. Review pin: `02b9d5ce4c73d0446146b13b46a228e6fd4d7735`, parent `112e0d910bd2b2c9a191f29536fecb0fbc0177b0`. All implementation observations below come from `git show` of that pin, not a mutable working-tree copy. The six-file diff contains 1,345 insertions.
|
||||
|
||||
**Outcome: no blocking S1 source defect found in this bounded review.** This is source-review evidence only: no compilation, tests, timing, memory measurement, native UI, source edits or Gitea writes. It neither establishes executable correctness nor approves public source8 exposure. Read root AGENTS.md, the r2 design and the S0/S1 handoff. Existing semantic types and exact-literal implementation were checked at the same pin. Shared coordination remains the parent's responsibility under the delegated review scope.
|
||||
|
||||
## Examined invariants
|
||||
|
||||
- `syntax.rs:159–204,531–550`: Pratt continuation uses precedence+1 for the right operand, preserving left association and right-grouped trees. Comparison rejection checks both children; grouping annotates the particular completed node and does not propagate to a newly constructed parent. Source inspection distinguishes `1 - (2 - 3)` from `1 - 2 - 3`, `(1 < 2) == true` from `1 < 2 == true`, and `true == (1 < 2)` from `true == 1 < 2`. The twelve operator mappings match the existing BinaryOp variants.
|
||||
- `syntax.rs:62–156,285–336,433–508`; `analysis_source.rs:46–88`: all thirteen Expr shapes are represented without numeric folding or conversion through JSON/f64. Prefix minus creates Unary; a negative ratio produces Number. Quantity suffixes are consumed only by the literal path, so `(1 + 2)[@unit]` cannot silently become a quantity with an expression magnitude. Sum requires its initial expression; constructor arity, trailing commas and signed integer Power bounds reject locally. Alias and parameter resolution is deliberately absent.
|
||||
- `lex.rs:16–138`; `syntax.rs:388–432`: a decimal/scientific atom comes from one contiguous raw numeric token. `1e-2` remains one token while `1-2` separates; malformed adjacency such as `1_0`, `0x1`, `1.2.3` rejects. Ratio components require integer digit tokens with an optional numerator minus, and bounded component lengths precede reconstruction. The reconstruction length constant correctly accounts for `ratio(`, comma+space and closing parenthesis. Original full literal and component/punctuation spans survive comments; only numeric spelling reaches the shared Exact meter.
|
||||
- `syntax.rs:227–268,367–386`: call argument names are checked for duplicate insertion before value parsing. Attempted entries and references are charged before name allocation. Repeated call sites retain separate callee/key spans because spans come from each occurrence rather than a callee/name lookup. This is enough syntax information for subsequent owner/path qualification; it does not itself implement that qualification or rename transactions.
|
||||
- `budget.rs:71–136`; `lex.rs:17–32`: byte/token/entry/reference counters share one explicit Budget across parse calls, reserve before relevant allocation, and cap arithmetic through subtraction before addition. Resource exhaustion latches; numeric admission uses one WorkMeter. Comments consume raw bytes and lexical trivia tokens. No per-literal reset or counter-overflow route was found through the intended methods.
|
||||
- `syntax.rs:48–337,443–479`: parser descent is an explicit task/value machine. Frames bound nested syntax; completed Expression children are checked before an accepted parent can exceed logical depth128. A failing proposed parent may own depth128 children, so its teardown has at most the one additional rejected parent layer. Flat binary trees cannot grow accepted depth without that check, and unfinished Sequence/Task containers hold bounded accepted child trees rather than a recursively nested task chain. This structural argument does not replace adverse-input execution and cleanup/RSS measurement.
|
||||
- `language.rs:4–7`: the only shared-language delta is the cfg(test) module hook. Existing public header rejection and generic tokenization implementations are unchanged in the reviewed diff. The test-staged raw lexer is not yet a tolerant public tokenizer, declaration grammar or public source8 reader; those gaps are acknowledged S2/S3/integration work, not regressions introduced by this pin.
|
||||
|
||||
## Nonblocking verification improvements before grammar exposure
|
||||
|
||||
1. **Assert mixed-precedence topology, not only parse success.** At `tests.rs:157–169`, `1 + 2 * 3` and `not(false) and true or false` only have successful parsing checked. Those assertions would also pass with several incorrect precedence tables. Add independently matched trees for those inputs and `-1 * 2`, `1 / 2 * 3`, `1 + (2 + 3)`; check actual operators and child placement. The current implementation looks correct by inspection, so this is an oracle-strengthening recommendation rather than a reproduced grammar defect.
|
||||
2. **Add a flat-binary depth boundary and a deep completed-child failure case.** `tests.rs:306–390` covers unary128/129, redundant groups256/257 and truncated wide syntax. Add 128 Boolean leaves joined by `and` (depth128 success), 129 leaves (logical rejection), and a constructor holding a completed depth128 subtree followed by a malformed second argument. These exercise Binary task ownership and teardown of completed deep children, which the existing short-prefix truncation test does not directly cover. Run them under the parent's executable/measurement gate; no stack or performance result is inferred here.
|
||||
|
||||
## Retained acceptance boundaries
|
||||
|
||||
The nine authored tests remain unrun in this review. Full declaration/type parsing, short-word registration, qualified scoped resolution, canonical AST-preserving writing, source indexes/edits, full-bundle aggregate charging, shared strict/tolerant tokenizer composition, old-version migration and guarded save remain outside this S1 pin. The r2 public whole-Project and literal-trivia update oracles remain necessary. Profile ceilings remain candidates pending measured adoption. No conclusion is offered on JSON depth enablement, Linux, evaluator behavior or native/API parity.
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"name": "analysis-source8-repair-design-r2-20260913.md",
|
||||
"sha256": "3a5b96fd130e3859932ee25544b3ff2f6a21f0774f3b5849e546f9574b9dbf7d"
|
||||
},
|
||||
{
|
||||
"name": "analysis-source-save-guard-independent-review-20260913.md",
|
||||
"sha256": "4222a566c3759201a8844e73e36c39bbea5faa39fbfbf382c4be69143c303d79"
|
||||
},
|
||||
{
|
||||
"name": "analysis-source8-s1-independent-review-20260913.md",
|
||||
"sha256": "08eefcb8870fb3e2f68b04f79d2c095171a976bdeb52c555e19095c840603662"
|
||||
},
|
||||
{
|
||||
"name": "analysis-source8-s0-s1-handoff-20260913.md",
|
||||
"sha256": "ea8b525daf0106c99ea5460aa64ce0c7a691e3dc583b93f4ac6642f0956c119e"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
# Canonical writer correction: unary Negate
|
||||
|
||||
2026-09-13. Applies to the staged S2 canonical writer. Source contract inspected at `472fe7a4339a23a3339b2b91e8d53d76442f60c4`; the subsequent test-only AsKind correction `a2a28f8d23a46ba1696b6a8410d4d3b5c1808f49` does not change that parser contract. This note records an accepted design/source-review correction, not writer implementation or executable roundtrip evidence. Public source8 admission, parser limits, model types and Exact semantics are unchanged.
|
||||
|
||||
## Superseded recommendation
|
||||
|
||||
The preserved [r2 design](analysis-source8-repair-design-r2-20260913.md), expression grammar section, recommends: “Canonical unary Negate always prints `-(...)`”. The initial S2 writer plan repeated that rule. **Mandatory grouping of every Negate operand is superseded** by the parent-aware prefix rule below. The r2 document remains byte-identical historical evidence, including its recorded copy SHA256 `3a5b96fd130e3859932ee25544b3ff2f6a21f0774f3b5849e546f9574b9dbf7d`.
|
||||
|
||||
The correction preserves the reason for the original recommendation: negative literal nodes and unary operation nodes must remain distinct. It removes only redundant syntax frames that can make an otherwise admitted typed tree unreadable after canonical writing.
|
||||
|
||||
## Counterexample and unchanged limits
|
||||
|
||||
Use a Number-typed property and a case override whose binding contains 127 Unary Negates around `Number(-1)`. The Expr tree has logical depth 128. An authored binding consisting of `- ` repeated 127 times followed by `ratio(-1, 1)` fits the existing frame profile by source inspection.
|
||||
|
||||
The staged declaration parser counts a frame for each Negate, each explicit group and each ratio constructor, alongside declaration/override braces. See [prefix and group parsing](../../crates/oa-core/src/language/analysis_source/syntax.rs), [shared frame cursor](../../crates/oa-core/src/language/analysis_source/cursor.rs) and the [unchanged limit256](../../crates/oa-core/src/language/analysis_source/budget.rs).
|
||||
|
||||
| Binding location and spelling | Active-frame calculation | Peak |
|
||||
| --- | --- | --- |
|
||||
| Case override, prefix minus without redundant groups | declaration1 + override1 + Negates127 + ratio1 | 130 |
|
||||
| Case override, superseded mandatory groups | declaration1 + override1 + Negates127 + groups127 + ratio1 | 257: exceeds256 |
|
||||
| Calculation body, prefix minus without redundant groups | declaration1 + Negates127 + ratio1 | 129 |
|
||||
| Calculation body, superseded mandatory groups | declaration1 + Negates127 + groups127 + ratio1 | 256 |
|
||||
|
||||
A calculation-only writer test therefore misses the case-override failure. These counts describe the current staged declaration parser. Future Project/include caller frames remain inherited context under S4; they must still be counted. This counterexample is not a general resource proof for every depth128 tree, and the correction does not raise the parser ceiling.
|
||||
|
||||
## Accepted writer policy
|
||||
|
||||
Emit Unary Negate as prefix `- ` and write the operand with unary-precedence parent context:
|
||||
|
||||
- Parenthesize a lower-precedence operand, such as a binary expression: `- (1 + 2)`.
|
||||
- Do not add a redundant group around another unary or an atom/constructor: `- - 1`, `- abs(1)`, `- ratio(-1, 1)`.
|
||||
- Keep the separating space in canonical prefix output. Existing source with other accepted spacing remains valid.
|
||||
- Number and Quantity magnitude continue to use [Exact::canonical_literal](../../crates/oa-core/src/analysis_numeric/exact.rs): negative literals remain `ratio(-n, d)`, including denominator1. No leading minus is substituted for a negative literal node.
|
||||
|
||||
The required topology discriminators are:
|
||||
|
||||
| Canonical binding | Model topology |
|
||||
| --- | --- |
|
||||
| `ratio(-1, 1)` | Number(-1) |
|
||||
| `- 1` | Negate(Number(1)) |
|
||||
| `- ratio(-1, 1)` | Negate(Number(-1)) |
|
||||
| `- - 1` | Negate(Negate(Number(1))) |
|
||||
| `- (1 + 2)` | Negate(Add(Number(1), Number(2))) |
|
||||
|
||||
No arithmetic evaluation, folding or reassociation is permitted. All other accepted precedence rules remain: group lower-precedence binary children; group a right binary child of equal precedence; group any comparison child of any comparison parent on either side; preserve ordered list and constructor children and callee-qualified call arguments.
|
||||
|
||||
## S3 subtree boundary
|
||||
|
||||
A changed subtree must be emitted with parent context derived from its owning binding field and typed child path. It cannot always use top-level expression context.
|
||||
|
||||
For example, S2a's operand span in `-(1)` includes `(1)`. Replacing that span with a binary subtree must emit `(1 + 2)`, retaining the necessary group. Replacing it with a negative Exact atom or another unary needs no redundant group; the retained prefix still constructs a Unary node. Adjacent retained/new minus prefixes remain separate tokens in the existing raw scanner. Constructor delimiters outside the selected value span remain owned by the parent.
|
||||
|
||||
This is a writer helper contract. S3 still owns original-span selection, comment handling, simultaneous edit composition and complete-model recompile validation. Alias-only updates continue to preserve original spelling and trivia; they do not invoke canonical subtree normalization.
|
||||
|
||||
## Required executable acceptance, not yet run
|
||||
|
||||
The writer milestone must author and execute these controls before claiming the correction implemented:
|
||||
|
||||
1. `negate_override_roundtrip_preserves_depth128_within_frame256`: start with authored complete Number property/case source using 127 prefix Negates around `ratio(-1, 1)`. Parse/lower/admit it; independently inspect exactly 127 Unary nodes and the negative Exact leaf; write the case; reparse/lower and compare the complete model and canonical byte stability. Assert the staged case peak 130 and calculation-body control 129.
|
||||
2. Source-parser controls for the superseded spelling: the case reaches257 and fails under the unchanged ceiling256, while the corresponding calculation reaches256 and passes. Preserve the distinction when later adding inherited Project/include frames to full-document tests.
|
||||
3. `all_expr_variants_and_precedence_preserve_exact_trees`: include every topology discriminator above alongside all13 Expr variants and existing binary/comparison grouping controls. Compare typed trees, not evaluated values.
|
||||
4. `subtree_output_respects_parent_context_and_retained_delimiters`: replace the entire grouped operand span in `-(1)` with an additive subtree, negative Exact atom and another Negate; reparse and compare the intended parent/child topology. Required grouping survives; unnecessary grouping is not forced.
|
||||
|
||||
Retain the separate width1024, output-byte/structural-work and cleanup controls. Existing S2b execution proves its own lowerer fixtures, not these future writer cases. No JSON depth-switch or native/performance acceptance follows from this source analysis.
|
||||
|
||||
## Decision provenance
|
||||
|
||||
The independent critic `/root/canvas_followup_acceptance_review` identified the257-frame counterexample and rechecked the corrected prefix/parent-context policy against the frozen scanner/parser. Parent accepted the correction. The reconciled local writer plan has SHA256 `e6ef6108792896cfb65e825cd19e984bf1c1729cff242653ed7947b4c6e9a803`; its exact correction diff has SHA256 `2e7d9f8627293301c450412466b7d2f27fac574cd4a0d1f09bccb2f36c1904be`; the source-review report including the bounded recheck has SHA256 `8ba73ffa442eeaa03f22f0137112a95aace199dbd7267f62de1737ba3e892ffa`. These identify design/source-review evidence only. The complete rationale and acceptance contract are recorded here so implementation does not depend on local artifacts.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Core JSON codec and Expr lifecycle candidate — 2026-09-11
|
||||
|
||||
Codex-956atf /root/analysis_core_resume. Isolated .local/analysis-core-worktree-20260911, branch codex/analysis-core-20260911, HEAD cdebb49d894952a189b144cc52deaf444e91d343. **Source is frozen and uncompiled. No codec runtime, memory, Windows release or Linux acceptance is claimed.** Root owns compilation, integration, dependency fetching/locking and Git. Linux execution/checking/cache work is explicitly deferred by the user ([357](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-357)); do not resume automatically.
|
||||
|
||||
The accepted cdebb49 static correction remains distinct: root's Windows depth128/129 and call32/33 test, analysis28/28, numeric26/26 and strict core all-target Clippy passed; independent review342 found no material new defect. Existing core WIP handoff and root iterative gate/review reports preserve the original STATUS_STACK_OVERFLOW failure at305 and immutable corrected274-input manifest/binary. This candidate changes lifecycle/persistence after that accepted source and needs fresh gates.
|
||||
|
||||
Lease [348](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-348). Design follows root harness/release-analysis-decoder-review-20260911.md / [257](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-257). Root maintains AnalysisCoreSchema820260911 Wiki; this worker does not concurrently rewrite it.
|
||||
|
||||
## Result and boundary
|
||||
|
||||
Persistence scans original bytes before Serde allocation, probes schema_version without a current-schema header Value, then decodes the same bytes with the closed typed schema. No Value-to-typed normalization or parallel accepting JSON grammar. The scanner handles escapes solely to meter spans/container boundaries; Serde still validates JSON, decoded names, UUIDs, Exact values, unknown fields/variants, duplicates and trailing bytes. Schema1–7 migrations retain frozen readers. Pre6 presence checks use a bounded legacy Value, discard it iteratively before typed construction, and inspect it iteratively in original traversal order. Legacy Serde recursion rejection remains enabled.
|
||||
|
||||
Expr keeps its public boxed enum shape. Clone, equality, Debug and destruction use heap traversal; ordinary and partial rejection teardown do not recurse with original expression depth. Clone fills child slots from copied metadata; equality preserves variant metadata, list order and parameter keys. Debug streams a compact structural representation without invoking child Expr Debug recursively. Destruction detaches children; reentry through emptied nodes/sentinel leaves has constant nesting. The manual-depth test constructs100000 nodes. This is not a promise that arbitrary in-memory values cannot exhaust heap memory.
|
||||
|
||||
A private Serde remote derive preserves the original tagged Expr grammar without an unguarded public inherent decoder. Each Serialize/Deserialize edge enters a depth/node guard before the visitor and a stacker boundary. List seeds refuse item1025 before deserializing it. Call keys are checked for duplicate decoded ParameterId and width before their values are constructed. A scoped persistence meter aggregates disjoint bindings and restores state on failure/unwind; standalone Expr serde has its own root meter. Static admission, contextual empty-list checking and nominal/exact algebra are unchanged.
|
||||
|
||||
Native file reads use fixed chunks and check before allocation extension, including growth after metadata inspection. JSON save uses a capped geometrically growing writer and compact JSON to avoid indentation expansion. The newline is inside the cap. Finished bytes are parsed with the same profile and the reopened Project is compared before creating a sibling temporary file. Cap/reader refusal preserves destination bytes. External-content comparison streams fixed chunks instead of reading an unbounded second file. The existing documented non-CAS filesystem race remains.
|
||||
|
||||
**DEPTH_128_VALIDATED is false.** Production retains serde_json's default guard and now refuses to save JSON outside that reader profile. Prepared tests pass true through the same private codec/decode/save implementations. Do not flip the production switch from formatting success, a depth-only pass or the old static gate. Root must assess complete local lifecycle evidence and the explicitly deferred Linux obligation. Logical depth128 remains the acceptance target.
|
||||
|
||||
## Explicit profile and allocation accounting
|
||||
|
||||
This private profile is separate from ParseLimits, the1,000,000 static-event meter and numeric work. Caller byte limits lower the64MiB document ceiling.
|
||||
|
||||
| Resource | Document ceiling | Qualification |
|
||||
| --- | ---: | --- |
|
||||
| Original / saved JSON including newline | 64MiB | Matches existing finite CLI/source-project envelope; replaces native unlimited read allocation. |
|
||||
| Live raw containers | 512 | Fixed scanner storage; deep Calls use about three containers per logical Expr plus a finite envelope. Gate emits actual counts. |
|
||||
| Lexical tokens including keys/scalars/punctuation | 16,777,216 | All original input, including rejected unknown/duplicate payloads, counted before typed allocation. |
|
||||
| Aggregate container entries | 8,388,608 | Object-member/array-entry cardinality admission. |
|
||||
| Entries in one ordinary container | 500,000 | Preserves declared500000 ordered-reference ceiling; no65536 cap on ordinary top-level maps. |
|
||||
| Raw string span / aggregate raw strings | 64MiB / 64MiB | No new1MiB metadata contraction. Valid encoded spans conservatively bound decoded UTF-8; caller byte cap applies first. |
|
||||
| One stored Expr tree | 65,536 nodes | Next Expr refuses before construction; dependency-closure admission remains separate. |
|
||||
| All Expr nodes per codec operation | 262,144 | Disjoint definition/base/case/check bindings aggregate; CLI250000-record budget still applies independently. |
|
||||
| Expr depth / List / Call arguments | 128 / 1024 / 65,536 | Ratified analysis bounds checked before child visitor/seed/value. |
|
||||
|
||||
These are finite allocation cardinalities/text spans, **not a measured allocator-byte or RSS ceiling**. The scanner has fixed storage/no heap allocation. Serde's probe uses bounded scratch for container markers/escaped keys without materializing ignored subtrees. Schema fixes Rust slot types, while collection capacity, allocator overhead and scratch affect actual memory. The output writer makes checked finite geometric reserves before adding bytes. Root must measure peak memory rather than describe the table as an exact process-memory guarantee.
|
||||
|
||||
Compatibility reasoning uses actual CLI100000 graph objects,250000 records,500000 ordered references/64MiB and server5000/20000/50000/8MiB. Prepared tests encode/reopen100000 ordinary typed graph records, scan500000 UUID references and preserve metadata above1MiB. They check codec/count compatibility, not full large-model validation/UI speed. General ceilings remain provisional document policy, not a theorem that every previously unlimited native shape fits. Initial1MiB string/65536 ordinary-map proposals were discarded.
|
||||
|
||||
Direct dependency: stacker0.1.25; oa-core enables serde_json's unbounded_depth API feature, but only the private candidate switch disables its guard. Root fetched/locked successfully without compilation; log .local/logs/analysis-codec-fetch-20260911.log. Cargo.lock adds42 lines only: stacker0.1.25, psm0.1.32, ar_archive_writer0.5.3, object0.39.1 and oa-core dependency edge; no existing versions changed.
|
||||
|
||||
Expr instrumentation requests64KiB reserve /512KiB segments; fixed outer document/validation requests512KiB reserve /2MiB segments. At most128 admitted Expr guards plus the outer boundary are live on this path. Capacity products are **not proved allocation/RSS ceilings**: overhead, base stack, allocator and outer work matter. Persisted Project/model/semantic-fact schemas inspected have no other recursively self-containing Rust field; vectors/maps contain fixed record/reference types. Ordinary Command/Project Expr operations receive the new traits, but generic caller closure is not implied.
|
||||
|
||||
Primary basis: [stacker maybe_grow](https://docs.rs/stacker/0.1.25/stacker/fn.maybe_grow.html), [platform support](https://github.com/rust-lang/stacker#platform-support), [serde_json lifecycle warning](https://docs.rs/serde_json/1.0.151/serde_json/struct.Deserializer.html#method.disable_recursion_limit), [Serde adapter warning](https://docs.rs/serde_stacker/0.1.14/serde_stacker/). Installed stacker/serde_json/serde_derive sources were read. Boundary instrumentation was chosen because a persistence-only serde_stacker wrapper does not protect generic callers' direct Expr serialization. The generic Value caveat below remains.
|
||||
|
||||
## Frozen paths and hashes
|
||||
|
||||
Only these source/dependency paths and this report changed. The lock is root-owned, included to identify exact input; worker did not generate it.
|
||||
|
||||
| Path | SHA256 |
|
||||
| --- | --- |
|
||||
| Cargo.lock (root) | 19499D93DDE6F2E2A00AB4B7D5F37689DD7A7D492B2B5E23063A761607839FC5 |
|
||||
| crates/oa-core/Cargo.toml | 3377D3D9D43FE0023AC09728B0D20205F86A6755F15518DC0CD035780B47A95D |
|
||||
| crates/oa-core/src/analysis.rs | 845C3629098A13F3FFFAAC3E37D6A3CF8B7BDF33A8110465459990EB3FE70B11 |
|
||||
| crates/oa-core/src/analysis/types.rs | 27363B1755F3C0943685A9837C69E010857811D4F67A0751B20EFDD9C1707AA3 |
|
||||
| crates/oa-core/src/analysis/expression_lifecycle.rs | 5D66E4E8F33FC8F90EC364251079617A36226E3EA68AC321ABAE74096E1C7C8C |
|
||||
| crates/oa-core/src/analysis/expression_serde.rs | 581E4704FB4F2049E31C7731267BC9D52E3F17625B664C3D7C5F19CAA961C819 |
|
||||
| crates/oa-core/src/persistence.rs | EDA5E697ADB29A8BF38B0D367099BD23267455E4792F97607E13DC0B232A3476 |
|
||||
| crates/oa-core/src/persistence/json_codec.rs | 2AA2C52EEA794ACB85744440192DF9EF5193160AD6EBD1FEBAA824E144AADD90 |
|
||||
| crates/oa-core/src/persistence/json_codec/tests.rs | 7065CC9E177A5699432888110936529C621F13E7C4B164566AB1FD14B61D6B9C |
|
||||
|
||||
Scoped installed rustfmt2024 parsing/formatting and git diff --check pass. Read-only textual check found no unmatched braces in authored raw format strings. These do not establish type/borrow/Clippy success. No worker Cargo/rustc/compiler/native/WSL/Git mutation.
|
||||
|
||||
## Root gates prepared, not run
|
||||
|
||||
Run from isolated worktree with root's existing Windows toolchain/cache:
|
||||
|
||||
1. cargo test --locked -p oa-core --lib persistence::json_codec::tests::codec_lifecycle_subprocess -- --exact --test-threads=1 --nocapture
|
||||
2. cargo test --locked -p oa-core --lib persistence::json_codec::tests -- --test-threads=1 --nocapture — **6 tests**, including the subprocess driver.
|
||||
3. cargo test --locked -p oa-core --lib analysis:: -- --test-threads=1 — **28 tests**:25 schema8 plus3 topology.
|
||||
4. cargo test --locked -p oa-core --lib analysis_numeric::tests -- --test-threads=1 — **26 tests**.
|
||||
5. cargo clippy --locked -p oa-core --all-targets -- -D warnings.
|
||||
6. Cover modified legacy/save boundary with existing JSON/migration tests: language's malformed_json_never_ignores_duplicate_maps_or_nested_unknown_fields, review_regressions' checked_save_ filter, and frozen migration suites. Integration suites also contain source-format/parser obligations; distinguish prior source8 gaps from new regressions.
|
||||
7. Repeat codec driver/filter with Windows --release after debug success; record child-phase peak memory/time and exact source identities. Linux debug/release remains explicitly deferred.
|
||||
|
||||
The exact driver invokes itself with a child-only phase environment variable. Each of five phases runs in another process on a **128KiB thread**, smaller than ordinary test stack; no RUST_MIN_STACK override. A stack overflow is a failed child identified by phase. Each child has a120s deadline.
|
||||
|
||||
- accepted: actual Project version dispatch/structural/static validation at unary128 and Call128; ordinary Project Clone/Debug/equality/drop, compact output/reopen, real sibling-temp save/read.
|
||||
- refused: logical129, List1025 with deliberately malformed rejected child, exact129/128 aggregate count, raw token cap, exact/lowered static events, stored-tree65536/65537 typed boundary, recovery after refusal.
|
||||
- malformed: late duplicate/escaped operand, unknown field, second enum variant, duplicate decoded parameter key, late Project field, trailing root, lone surrogate, raw100000-container refusal; original-byte behavior/partial typed teardown.
|
||||
- ownership: programmatic100000-node invalid-depth Expr Clone/equality/streamed Debug, safe serialization refusal and ordinary drop.
|
||||
- atomic: exact bytes/newline, cap-minus-one preserves destination/no temp artifact, conflict refusal, finite read and no writer growth on refusal.
|
||||
|
||||
Other tests cover exact lowerable scanner resources, escapes/odd backslashes/encoded brackets and syntax rejection, all13 original variants, declared large-container compatibility, and production save/reader-profile symmetry. Gate output emits actual accepted bytes/tokens/entries/raw strings/container depth; these are not memory measurements.
|
||||
|
||||
## Remaining obligations
|
||||
|
||||
- No compilation yet. Borrow/exhaustiveness/remote-derive/platform behavior and all prepared assertions need real root gates. Production deeper decoding stays disabled pending that decision.
|
||||
- Profile is private to persistence. Server commands/requests/responses, CLI JSON ingress, hosted envelopes, error/output caps and full channel limits remain integration obligations.
|
||||
- Source8 grammar/format/round-trip/conversion is not implemented. language.rs stays with9e8r6t's outline seam. The .arcadia formatting path remains separate; its allocation/recursion closure is not claimed.
|
||||
- Generic to_value callers can still materialize a deep serde_json::Value and then invoke Value's own recursive Clone/Debug/equality/drop. Protecting Expr Serialize does not change that foreign lifecycle. Semantic catalog/source/response Value adapters require integration audit; persistence8 does not use that carrier.
|
||||
- Fixed outer stack reasoning/requested segments are not runtime acceptance. Windows debug/release/memory remain required. Linux is deferred by user without automatic retry.
|
||||
- Evaluator, quantities/nominal algebra, exact/numeric limits, static1m accounting, baseline/case/unused branches, scoped parameter identity and fixed KindHandle are unchanged. Full schema8 transport/source/product/release acceptance remains open.
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Schema8 core source handoff
|
||||
|
||||
## Current root execution and review — 2026-09-11
|
||||
|
||||
Root completed the corrected required depth128/129 and call32/33 regression
|
||||
(1/1), the complete `analysis::` filter (28/28:25 analysis plus3 topology),
|
||||
numeric26/26, and strict core all-target Clippy with `-D warnings` (exit0,3m37s).
|
||||
The default Windows test stack and required limits were unchanged. Independent
|
||||
`analysis_core_review` found no material new correctness/resource defect in the
|
||||
iterative checker/admission correction; [Gitea342](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-342)
|
||||
records its conclusion. Source hashes match the correction freeze below.
|
||||
|
||||
Root's durable `harness/release-analysis-core-iterative-gate-20260911.md` and
|
||||
`harness/release-analysis-core-iterative-review-20260911.md` record execution and
|
||||
independent scope. Immutable root `.local/analysis-core-iterative-gate-20260911/`
|
||||
contains274-input manifest SHA256
|
||||
`28D6CEAE5C52765F5DB5CB411D7E42EDAB91359C9E351663315BC4B7F1FF2B7A`
|
||||
and corrected test executable SHA256
|
||||
`2B4EE8BA63B20415B352E084314CC7504E778C093598B9564C6EA4F56BC921C9`.
|
||||
Clippy log SHA256:
|
||||
`6FE529BBA6A761FEDACB1A3933BB86E4FB644D8CC192F57FB0F45EFA5B9ABFFF`.
|
||||
|
||||
This supersedes the awaiting-rerun status below, preserving the first actual
|
||||
failure. It does not close full core integration, bounded codec/lifecycle/output,
|
||||
source8, evaluator, UI/REST/CLI parity, libraries or release acceptance. The source
|
||||
remains isolated from root's runnable schema7 application.
|
||||
|
||||
## Historical stack-overflow correction source freeze — 2026-09-11
|
||||
|
||||
Codex-956atf `/root/analysis_core_resume`, renewed debugging lease after root's actual first Windows gate. The same dedicated worktree/branch remains at HEAD `b96ff3935ab3f36b7144566e46559a05a29292a5` plus uncommitted corrections. **The first candidate failed; this replacement is frozen source awaiting root's rerun.** No compiler was run by the worker.
|
||||
|
||||
Root's original `cargo test --locked -p oa-core --lib analysis:: -- --test-threads=1` compiled warning-free in3m34s, discovered28 tests, logged16 named passes, then aborted at `analysis::tests::logical_depth_and_call_depth_have_exact_boundaries` with Windows `STATUS_STACK_OVERFLOW` / `0xc00000fd`. Preserve the original root `.local/logs/analysis-core-first-20260911.log` (SHA256 `84C6B63039472363412B66C7A24DE825A846518725EC9F7436C252603AEF54FC`) and root-reported binary `target/debug/deps/oa_core-a7a95d666500007c.exe` (SHA256 `548850D3606C9B4A58859677E86A1C1813EB6E71785C529F771E04125E7A33AF`). Gitea issue1 comment305 records the actual failure. Neither compile success nor the16 earlier passes establish a full gate pass.
|
||||
|
||||
The test-count discrepancy is resolved: Rust's substring filter `analysis::` selects25 schema8 tests **plus three existing `topology_context::analysis::tests`**, declared through `topology_context/analysis.rs` into `analysis_tests.rs`. The earlier prepared25 was the schema8-module count, not the full filter count; the saved22 would likewise have selected25 with those topology tests. The current corrected filter still expects28, with no ignored/removed or additional test functions.
|
||||
|
||||
The large recursive `Checker::expression` was the concrete source-level suspect; no profiler/frame-size measurement established the exact abort instruction. It is now an explicit continuation machine with at most128 heap frames and one returned type. Each frame borrows its Expr and any calculation definition and owns only fixed-size resolved types/handles. Lists and calls schedule one ordered child at a time. The rejected129th frame is never allocated, while its entry event is still charged to preserve failure precedence. Rust call depth no longer scales with Expr depth, and typechecker-frame destruction does not destroy the borrowed Expr tree.
|
||||
|
||||
The continuation states retain left-before-right operands, condition-before-both-branches, Sum initial-before-values, AsKind target resolution-before-operand, declaration-order call arguments, contextual empty-list typing, branch type equality and existing node/key/argument meter precharges. No numeric operator semantics, fixed KindHandle, lowerable1,000,000-event ceiling, accepted128-depth bound or stored schema was changed. No stack-size override, release-only workaround or ignored test was introduced.
|
||||
|
||||
The original failing test now includes diagnostic phase markers and unary128/129, mixed Binary/If/Call/Power/Unary128/129, contextual empty-list If/Call128 and definition-call32/33 boundaries. The existing parameter-purity test now makes call declaration order versus UUID order and binary left-to-right error precedence observable through distinct invalid-reference errors. The already compiled meter tests retain their exact event expectations.
|
||||
|
||||
| Path | SHA256 at correction freeze |
|
||||
|---|---|
|
||||
| `crates/oa-core/src/analysis/typecheck.rs` | `831F14346BBAE83FBDEEB744F9F29460FAB13A8315645FAA6CE94D0B60B1516F` |
|
||||
| `crates/oa-core/src/analysis/tests.rs` | `9FB30B28E305CB00E7C39204912F8311E96AA92D0334CE4A73FB08FADDFE83C5` |
|
||||
| `crates/oa-core/src/analysis/admission.rs` (unchanged since first candidate) | `5F85EAA868AC43C4819C98556281D8226F9C0E2A4B11E5C97CF24D855D2127AE` |
|
||||
|
||||
Standalone installed rustfmt2024 check and `git diff --check` pass for the correction. Root's next commands from this worktree, using its established toolchain/cache:
|
||||
|
||||
1. `cargo test --locked -p oa-core --lib analysis::tests::logical_depth_and_call_depth_have_exact_boundaries -- --exact --test-threads=1 --nocapture`
|
||||
2. `cargo test --locked -p oa-core --lib analysis:: -- --test-threads=1` (28 expected).
|
||||
3. `cargo test --locked -p oa-core --lib analysis_numeric::tests -- --test-threads=1` (26 expected).
|
||||
4. `cargo clippy --locked -p oa-core --all-targets -- -D warnings`.
|
||||
|
||||
Admission's structural Expr/reference walks and dependency/case graphs are already iterative. Inspected architectural index construction borrows analysis records and clones only ordinary step-ID vectors; its analysis-reference branch avoids cloning Expr-bearing elements. This identifies no further recursive Expr traversal in the immediate admission path, but is not execution or stack/RSS evidence. Derived Expr Clone/Debug/equality/Serialize/Deserialize/Drop, partial decode teardown, command cloning, output expansion and full codec/lifecycle/source parity remain the separately documented production risks. Default Serde recursion rejection remains unchanged. Root owns execution, follow-up findings, integration and release publication; there is no complete schema8 transport or release claim.
|
||||
|
||||
## Historical first resumed source freeze — 2026-09-11
|
||||
|
||||
Codex-956atf `/root/analysis_core_resume`, resumed by root after the user returned. **Source frozen for root's first compilation gate; still uncompiled staging.** The dedicated worktree remains `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/analysis-core-worktree-20260911`, branch `codex/analysis-core-20260911`, HEAD `b96ff3935ab3f36b7144566e46559a05a29292a5`. No commit was made by this worker. The reboot record below is retained as historical evidence; this section supersedes its stopped/pending-static-review status only.
|
||||
|
||||
The resumed delta changes three Rust files plus this report:
|
||||
|
||||
| Path | SHA256 at resumed freeze |
|
||||
|---|---|
|
||||
| `crates/oa-core/src/analysis/admission.rs` | `5F85EAA868AC43C4819C98556281D8226F9C0E2A4B11E5C97CF24D855D2127AE` |
|
||||
| `crates/oa-core/src/analysis/typecheck.rs` | `1AC3A92E97B886F4BFAC31BA22C182347131D0A82BEDBF6B2F2073D02AD5A6E4` |
|
||||
| `crates/oa-core/src/analysis/tests.rs` | `9EC9C12C618600039BC2DC0A9B9BF6DC5378FD4D8749E21EDEC699EEE06AB0E2` |
|
||||
|
||||
- `admission.rs` precharges expression child slots before queue growth, rejects visited-plus-pending closure overflow before allocation, meters initial closure queue entries, text spans and scoped parameter names, and charges each relation-subject traversal hop. Known indexed element layers use direct map lookup. This closes analysis-specific unmetered work; it does not bound the existing architectural validator, wire decoding or recursive lifecycle operations.
|
||||
- `typecheck.rs` groups owner/field/layer/parameters in `BindingContext`, removing the eight-argument helper. Call arity remains an O(1) check; the complete scoped-key scan is precharged before lookups, then declaration-order argument lookup/type work is separately charged. Actual parameter identity and all-branch checking are preserved.
|
||||
- Three new meter tests exercise 1,024-wide List/Call precharge, a 64-parameter malformed/valid Call at exact work boundaries, and 64 relation hops with terminal element/missing/cyclic endpoints. The existing real-Project aggregate-limit test also checks text-byte accounting and exhaustion before invalid text inspection. These relation-chain fixtures deliberately test malformed input before architectural admission, not newly accepted architectural relations.
|
||||
|
||||
**Expected focused count is now25: the saved22 plus three new meter tests (21 actual-Project tests and four meter tests total).** Command from this worktree: `cargo test --locked -p oa-core --lib analysis:: -- --test-threads=1`. Root owns the toolchain/cache configuration and all execution. Follow with the changed numeric-helper regression filter `analysis_numeric::tests`, then strict core Clippy; the earlier26 numeric passes apply to7d14bd0, not this schema8 candidate.
|
||||
|
||||
Read-only review covered existing persistent-enum/Project constructors, command/reference call sites and the analysis lifetime/borrow paths. Standalone installed `rustfmt --edition 2024 --check` passes for all three changed Rust files; `git diff --check` is clean. **No Cargo/rustc, test, Clippy, native, Linux or memory/stack measurement was run by this worker.** No root-checkout files, manifests, lockfiles, ledgers, fixed KindHandle, numeric semantics or other worktree-owned source changed during resume.
|
||||
|
||||
The lowerable aggregate1,000,000 static-event ceiling, nominal/exact arithmetic, baseline/case/unused-branch checks and scoped parameters remain settled. Default serde_json recursion rejection stays enabled. Schema8 codec/allocation/output/lifecycle closure, source8, evaluator, package environment, capture, API/CLI/UI and historical/transition parity remain pending; this is not a runnable release or complete product transport. Gitea issue1 comment291 records the resume lease; root owns integration/publication and any subsequent compiler-driven corrections.
|
||||
|
||||
## Historical reboot freeze
|
||||
|
||||
Codex-956atf /root/requirements_delta, 2026-09-11. **Frozen immediately at the user's reboot checkpoint; uncompiled work, not a runnable product. Do not resume without root follow-up after reboot.**
|
||||
|
||||
Worktree: `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/analysis-core-worktree-20260911`, branch `codex/analysis-core-20260911`, base `7d14bd0dd1cf4a2293c00c185573fd4a7fc364ae`. All edits in this assignment are in that isolated worktree. Root retains commits, pushes, builds, integration and the original checkout. No manifests, lockfile, source8 grammar, evaluator, server/CLI/UI, generated schemas, ledgers or prior frozen reports were edited.
|
||||
|
||||
## Coherent implementation present
|
||||
|
||||
- Schema8 `Project.definitions` is required. Local quantity-kind, unit and pure calculation definitions share global UUID/alias/header identity; `ParameterId` is scoped to its calculation. `DefinitionRef::Local` is the only admitted reference variant. Imported packages are not simulated.
|
||||
- Three explicit `SharedBody` records implement property-owned bases, subjectless or subject-bearing one-level cases, and shared checks. `Binding::{Unbound, Expression}` has no persisted Clear/default-case state. The closed expression/type vocabulary is in `analysis/types.rs`.
|
||||
- `analysis::admit(&Project, AdmissionLimits)` returns the resolved kind/unit/calculation environment, accepted limits and usage. `effective_binding(property, ModelBaseline|Case(id))` returns the selected binding and its origin; it does not evaluate. Private bounded `KindHandle` contains project and definition UUIDs and is valid within one admitted environment.
|
||||
- Static checking uses pure `NumericType` result helpers also used by `NumericValue`. It checks every stored branch, pure parameter scope, exact call arguments, declared/display types, same-layer subjects/property reads/case targets, affine kinds/units, flat homogeneous lists, acyclic calculation/effective-property dependencies, logical depth128, call depth32 and stored closure65,536. Checks may be Boolean false; supported runtime domain failures such as 0/0 do not become typing errors.
|
||||
- Root ratified the provisional static ceiling1,000,000 events, explicitly lowerable and separate from runtime work. One meter spans the document and every effective case; it latches on exhaustion. Final ceiling acceptance still needs a measured gate. This meter is analysis work accounting, not a bound on all existing architectural validation or JSON allocation.
|
||||
- Commands add Create/EditDefinition, SetAnalysisBase, SetAnalysisOverride and ClearAnalysisOverride. General element creation/editing remains the actual shared command path. Reidentification rewrites property/local-definition references and case keys, preserves scoped parameters, and rejects colliding keys before mutation. Deletion retains live analysis dependencies, so the same transaction must repair or remove them. Existing Session history/provenance is reused.
|
||||
- Frozen schema5/6/7 JSON envelopes migrate with an empty catalog and reject new fields/kinds. Existing descriptive units/constraints remain strings. `language.rs` contains only old-source compatibility/refusal and record-count glue. Explicit guards refuse header-only semantic review or transition of analysis records.
|
||||
|
||||
## Evidence and remaining risks
|
||||
|
||||
**22 tests are prepared, zero were compiled or executed by this worker:**21 actual-Project tests and one static-meter test. The independently specified complete17-UUID response-budget fixture covers shallow JSON reopen, base/case/Unbound selection, commands/history, identity domains, atomic deletion repair, case-only cycles, unused branches, parameter purity, types/units, R6/runtime agreement, lists, logical/call/closure boundaries, aggregate static work, transport record counts, closed wire input, old-schema metadata preservation and unsupported review/transition refusal. The expected mixed-unit evaluation totals are not claimed; there is no evaluator in this lease.
|
||||
|
||||
Standalone rustfmt parsed/formatted the new modules, migration_v7 and changed numeric quantity file. A read-only `git diff --check` before the stop request was clean. Neither is a compile/test gate. Root reported the earlier numeric foundation at7d14bd0 passed26 tests; that result does not cover this WIP or its modified numeric helper code.
|
||||
|
||||
The default serde_json recursion guard is deliberately unchanged. Some programmatically admitted depth128 trees cannot reopen through the current decoder. Pretty serialization can exceed64MiB while semantic node limits still fit. Header `Value` allocation and per-Exact String/BigInt decoding precede aggregate static admission. Clone/Serialize/Debug/Drop and command cloning need the coherent bounded-codec/lifecycle gate. See the independent decoder review `harness/release-analysis-decoder-review-20260911.md` in the original checkout (Gitea#257). **Do not publish a runnable schema8 product before codec/output closure.**
|
||||
|
||||
Source8 parsing/formatting/update/history parity is pending: the model/schema header is8 while the existing source parser still accepts only3..7. Existing full source regression tests therefore cannot be represented as passing. Old synthetic tests that serialize a current Project after merely changing its schema number may need to remove the new catalog field to represent a real frozen old envelope. Generic factory creation refuses analysis kinds because required semantics cannot be defaulted.
|
||||
|
||||
Compiler/borrow/exhaustive-match checks remain outstanding. Static review was interrupted: the private typechecker binding helper has8 arguments and may trigger strict Clippy's argument-count lint; grouping its located binding context was the next cleanup under consideration, not applied. Review the static event precharge coverage of the call-argument arity/key scan before treating its weights as final. Existing architectural validators still have their own scaling costs. No claim of full decoder safety, source parity, frozen export, package compatibility execution, approximate mathematics or finished release capability is made.
|
||||
|
||||
## Exact edited paths
|
||||
|
||||
All paths below are relative to the isolated worktree, not the original checkout. This report is the only additional edited file beyond this20-file inventory.
|
||||
|
||||
| Path | SHA256 at stop |
|
||||
|---|---|
|
||||
| `crates/oa-core/src/analysis.rs` | `7C197D80CD6C5A17345E9ABD20107CF9EE1887A77596144A8FAADB3253A9664B` |
|
||||
| `crates/oa-core/src/analysis/admission.rs` | `60661ADEBFBDFA2A04A354A106D3DB3F81821EF4A50786A6CF611988666FDB85` |
|
||||
| `crates/oa-core/src/analysis/references.rs` | `FE50EDC049B9900D91CADE3F58D15ACA4101211C39BB090317F548259D634529` |
|
||||
| `crates/oa-core/src/analysis/resolve.rs` | `67AAA97DA8190DEDE71F0E2F98F1884A1DA38596B4B2DC6F8D61FF4377BDC80F` |
|
||||
| `crates/oa-core/src/analysis/typecheck.rs` | `23F13E0EF971E794210FB3886178E1145661E4BC668A50B753F7BAAA9591035E` |
|
||||
| `crates/oa-core/src/analysis/types.rs` | `CFD03E2B01D34AE6979403458D9861B7B1252383A2313CB56133AB64167F72B2` |
|
||||
| `crates/oa-core/src/analysis/tests.rs` | `7137A47E994E629D7F349D023F32C10FD0563261678E59878A23E3BF6A101F05` |
|
||||
| `crates/oa-core/src/analysis_numeric/quantity.rs` | `B9F23A9485F8BD913722023C35FB9D8917C9F160078655B3A5C52C8F7F12AB94` |
|
||||
| `crates/oa-core/src/language.rs` | `B65098321ECF79E4DC1182A7FAB7BAFEEC0A238BB56153E81782F04FAB838558` |
|
||||
| `crates/oa-core/src/lib.rs` | `C9BF3462A57A465B6CC7ECD7866E0D331B35F299E89D9C917E0A4587BB1DCD89` |
|
||||
| `crates/oa-core/src/migration_v4.rs` | `BF4056DA11AC4F27627320742539CC2408ED42334106F3068C432FF70C62DB25` |
|
||||
| `crates/oa-core/src/migration_v7.rs` | `474F4CEBDD343A7EECDF243149BBCBF08E12EAA05B8532BF2593705287017B30` |
|
||||
| `crates/oa-core/src/model.rs` | `1C06BCC4AA7ABFEF89876C3A001171C742558D664612CF36152AC0AA52BC7BAF` |
|
||||
| `crates/oa-core/src/persistence.rs` | `30B05FA464C49629080F3E20C6D3DA5226858A99B7C4C9554356847F8A5975EE` |
|
||||
| `crates/oa-core/src/references.rs` | `63CD8655F67D919CC8F6B796F4CEC42D7B020AA37880E50F19190AAB8B0029A3` |
|
||||
| `crates/oa-core/src/semantic/catalog.rs` | `47D74ACC4862139BA835DB3D6B769411E27860A99426A7F03EDEA26F44F7F7B3` |
|
||||
| `crates/oa-core/src/session.rs` | `06115011BC86F0C95BBBE6D14BDAEDD7B83A66C0104DB186900B0F047B0F9C43` |
|
||||
| `crates/oa-core/src/transitions.rs` | `127E429BCA4B2EA9A189ECE7E7B6DFD05A30B5FDEE98B2D10288A6F7C17B5931` |
|
||||
| `crates/oa-core/src/validation.rs` | `E4C61F5ABB22AE3E595E16E93472E4364CDE4DB9064EC6DF826228C3D5962E42` |
|
||||
| `crates/oa-core/tests/source7_contract.rs` | `0702D3BD0173DEF4F67770458E4AF2B23F213FD5938EE70E3AE0042897EF5F1E` |
|
||||
|
||||
## Next authorized gate after reboot
|
||||
|
||||
Root first preserves/pushes this WIP and confirms the exact checkout. After root explicitly resumes work, the proposed narrow root-owned command from this worktree is `cargo test --locked -p oa-core --lib analysis:: -- --test-threads=1`, using the established toolchain and root-selected target directory. Fix actual compiler/test findings, rerun modified numeric tests, then strict core Clippy; only then continue independent review and coherent bounded codec/source8/evaluator integration. This worker has not run that command and will remain stopped.
|
||||
Reference in New Issue
Block a user