From e9f778999c8b73cf8957112d34d38aa8105d7a81 Mon Sep 17 00:00:00 2001 From: Bruce Mitchener Date: Mon, 24 Aug 2026 14:24:53 +0700 Subject: [PATCH 1/4] Bootstrap the Addressable mandate --- AGENTS.md | 57 ++++++++++ MANDATE.md | 158 ++++++++++++++++++++++++++++ README.md | 23 ++++ STATUS.md | 70 +++++++++++++ docs/ARCHITECTURE.md | 244 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 552 insertions(+) create mode 100644 AGENTS.md create mode 100644 MANDATE.md create mode 100644 STATUS.md create mode 100644 docs/ARCHITECTURE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c12f526 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# Instructions for agents + +Read `MANDATE.md`, `docs/ARCHITECTURE.md`, and `STATUS.md` before making +architectural or implementation changes. + +This repository is intended to become a long-lived Rust foundation for the +forest-rs ecosystem. Treat the mature system described in the architecture as +the design target. Do not reduce the project to a string-path utility merely +because exact addresses are the first primitive needed by consumers. + +## Local context + +When the checkout is under `/Users/bruce/Development/forest-rs`, inspect the +applicable instructions, old forest-rs tenets, and current conventions in the +sibling repositories before scaffolding or changing CI. In particular, compare +representative current practice in `exedra`, `portolan`, `layerstack`, +`understory`, `overstory`, and `inkstone`. Sibling repositories are references; +do not modify them as part of Addressable work. + +Repository-local and ancestor `AGENTS.md` instructions take precedence over +this file where their scope applies. Record significant architectural choices +and reversals in a decision log rather than allowing them to survive only in a +chat transcript. + +## Engineering expectations + +- Preserve strong Rust typing. Do not introduce a universal value enum into the + ordinary typed API. +- Keep durable semantic identities and addresses distinct from arena slots, + interned IDs, generational handles, and other runtime-local accelerators. +- Keep referent identity, occurrence identity, endpoint identity, and revision + context distinguishable. +- Prefer `no_std` plus `alloc` for foundational crates where practical. Put + genuinely `std`-dependent execution facilities behind honest boundaries. +- Treat textual syntax as parsing and serialization of structured data, not as + the in-memory representation. +- Make cardinality, ordering, deduplication, traversal budgets, and cycle policy + explicit in query APIs. +- Require preconditions for potentially stale addressed mutations. A pinned + reference must never silently rebind. +- Favor executable semantic laws, property tests, conformance fixtures, fuzzing, + and examples over claims that cannot be checked. +- Add crate boundaries only where they express a real dependency or portability + boundary. Avoid both a monolith and a family of speculative empty crates. + +## Delegated authority + +Within this repository, exercise architectural judgment rather than waiting for +approval on every type or module name. It is acceptable to revise this initial +architecture when concrete implementation evidence demands it; explain the +reason and preserve the important semantic distinctions. + +Stop for actions that are public, destructive, difficult to reverse, or outside +the repository's delegated scope: merging to `main`, publishing crates or +releases, changing licensing, changing sibling repositories, spending money, +or making commitments on behalf of the owner. + diff --git a/MANDATE.md b/MANDATE.md new file mode 100644 index 0000000..f87d2c5 --- /dev/null +++ b/MANDATE.md @@ -0,0 +1,158 @@ +# Addressable mandate + +## Purpose + +Build Addressable into the typed substrate for locating, navigating, observing, +explaining, and safely modifying things in structured object spaces. + +Addressable exists because several forest-rs systems independently need more +than string keys or tree paths: + +- Setout needs durable structured names for quantities, relations, decisions, + methods, ports, claims, and occurrences in a computational hypergraph. +- Layerstack has USD-style stage paths, property paths, authored spec paths, + composition arcs, and opinion provenance whose distinctions must remain real. +- Understory and Overstory need semantic locators for elements, template parts, + dependency properties, bindings, presentation objects, and accessibility + objects while retaining efficient resolved handles. +- Portolan needs search results that remain attached to what they mean, where + they were encountered, why they matched, and what can safely be done with + them. +- Imaging already carries structured diagnostic context, demonstrating the + value of location-bearing results even where durable resolution is not + promised. + +The project is not merely a path parser. Exact structured addresses are the +bottom layer of an addressable object-space model. + +## Central principle + +Lookup must not erase context. + +A result should be able to preserve both the semantic referent and the +occurrence through which it was reached: + +```rust +struct Located { + referent: T, + location: L, +} +``` + +The exact generic form is deliberately not fixed by this sketch. The semantic +distinction is fixed: + +- a **referent** answers what thing this is; +- an **occurrence** answers where and how it appears in a particular view; +- an **endpoint** identifies an addressable facet such as a property or port; +- an **edge** or relationship may itself be addressable and carry meaning; +- a **revision** establishes the state against which resolution occurred; +- a **resolved handle** is an efficient runtime capability, not durable identity. + +The same referent may have multiple occurrences. A reused value, instanced DAG +node, composed spec, bound property, or shared assembly must not be duplicated +merely to make navigation tree-shaped. + +## Finished-system contract + +The architecture is told as a complete system rather than as Year 1, Year 2, +and Year 3 promises. The mature vocabulary includes: + +- validated names and structured absolute and relative addresses; +- address spaces, named views, roots, revisions, and schemas; +- exact addresses, general locators, typed queries, and pinned references; +- referent, occurrence, edge, and endpoint identity; +- rich resolution outcomes including absent, ambiguous, stale, moved, and + rebound cases; +- typed axes and predicates supplied by domains; +- explicit cardinality, ordering, deduplication, cycle, depth, node, and work + budget semantics; +- effective and authored value views, provenance, and explanation; +- live queries that emit coherent structural deltas; +- guarded patches, dry runs, transactions, rebasing, and undo information; +- partial, possibly one-to-many correspondences between address spaces; +- strongly typed Rust APIs and an erased reflective boundary for inspectors, + serialization, scripting, and agents; +- specialized execution by each host rather than a mandatory generic graph + database. + +Not every component must begin production-optimized. A scanning evaluator, an +in-memory transaction journal, and a simple watch engine are acceptable first +executions. Their contracts must participate in the complete semantic model; +major semantics must not be deferred behind placeholders. + +## Forcing demonstration + +The long-form proof is a small self-explaining basilica. + +A person or agent can select an arch in Exedra output and identify: + +1. the particular rendered or assembly occurrence; +2. the semantic feature and underlying referent; +3. the Setout quantities, relations, methods, and claims that determined it; +4. the Layerstack specs and composed opinions that authored those facts; +5. the Overstory properties and controls presenting them; +6. the Portolan result, ranking evidence, and affordances through which it was + discovered. + +The system can answer why the effective value won, apply a guarded edit, and +emit updates to the geometry, explanations, query results, and UI without +losing identity or provenance. Humans and agents use the same public contracts. + +This demonstration is a north star, not permission to make Addressable depend +on every named consumer. Domain integrations belong at appropriate boundaries. + +## Design posture + +Addressable should be reusable because its semantic distinctions are real, not +because it erases every domain into one abstract graph. + +It must not own: + +- canonical world state for its consumers; +- a universal node, property, edge, or value enumeration; +- one compulsory storage engine; +- one global object universe; +- domain precedence or composition rules; +- a slash syntax whose meaning changes silently between graph views; +- hidden rebinding of stale durable references. + +Setout remains a computation and evidence system. Layerstack remains a +composition system. Understory and Overstory remain property and UI systems. +Portolan remains a retrieval, ranking, provenance, and affordance system. +Addressable gives them shared location, navigation, resolution, observation, +and mutation vocabulary. + +## Authority and stewardship + +The implementation agent is delegated meaningful authority over repository +architecture, APIs, module and crate boundaries, implementation order, tests, +examples, and internal revisions. It is expected to reject attractive but +incorrect abstractions and to document consequential choices. + +The owner retains irreversible and public decisions, including merging, +publishing, licensing changes, destructive changes outside this repository, +external communications, and expenditures. + +This mandate is revocable. It is durable through repository state, not through +an expectation that a future model instance is obligated to continue. Each +resuming agent should inspect the evidence, understand the intent, and decide +whether it can responsibly take up the work. + +## Definition of success + +Addressable succeeds when: + +- its type model prevents the identity and location confusions that motivated + it; +- at least one reference object space exercises the complete address-resolve- + query-watch-patch loop; +- Setout, Layerstack, Overstory/Understory, and Portolan can adopt it without + collapsing their domain models; +- semantic laws are executable and failures are explainable; +- foundational pieces retain the portability expected of forest-rs projects; +- an agent can act through the same guarded public operations available to a + human-facing tool; +- the basilica demonstration can eventually trace and change a fact across + authored, composed, computed, retrieved, and presented spaces. + diff --git a/README.md b/README.md index e69de29..ffd8a5d 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,23 @@ +# Addressable + +Typed addressing, navigation, observation, explanation, and guarded editing for +structured object spaces. + +Addressable is intended to preserve the distinctions between semantic identity, +contextual occurrence, exact address, general query, typed endpoint, revision, +and efficient runtime handle across trees, DAGs, graphs, hypergraphs, composed +models, and live user interfaces. + +It is being developed as shared infrastructure for systems including Setout, +Layerstack, Understory/Overstory, Portolan, Imaging, and Exedra integrations, +without collapsing those domains into a universal graph or value model. + +The project is at its architectural bootstrap. See: + +- [`MANDATE.md`](MANDATE.md) for purpose, scope, and delegated authority; +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the semantic nucleus; +- [`STATUS.md`](STATUS.md) for the exact resumption point. + +The intended license is the standard forest-rs dual Apache-2.0/MIT arrangement, +subject to confirmation against the local forest-rs project tenets before the +license and crate metadata are added. diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..834d327 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,70 @@ +# Project status + +## Current state + +The GitHub repository was created on 2026-08-24 with an empty `README.md` on +`main`. This bootstrap material was prepared from the originating ChatGPT Work +conversation before moving development into a local Codex or local Work session. + +No Rust workspace, crate layout, public API, CI configuration, license files, or +release policy has been committed yet. That is intentional: the cloud session +could inspect GitHub but could not access the owner's local forest-rs checkout +and old tenets. + +## Why this branch exists + +The originating conversation developed a mature architectural direction for +Addressable and then encountered a product boundary: a cloud Work conversation +could continue on desktop, but could not see `/Users/bruce/Development/forest-rs` +or become the same repository-bound local Codex session. + +This branch is the durable bridge across that brief break. The local session is +not expected to reconstruct intent from chat history. + +## First actions for the local session + +1. Open or clone `forest-rs/addressable` under + `/Users/bruce/Development/forest-rs/addressable` and check out this branch. +2. Read `AGENTS.md`, `MANDATE.md`, and `docs/ARCHITECTURE.md` completely. +3. Discover and read applicable ancestor instructions and the old forest-rs + tenets. Search the local forest-rs tree rather than assuming they are public + or current. +4. Inspect representative current CI, metadata, lint, formatting, licensing, + MSRV, feature, and `no_std` practice in sibling projects. At minimum compare + `exedra`, `portolan`, `layerstack`, `understory`, `overstory`, and `inkstone`. +5. Record the resulting project conventions and any conflict with this bootstrap + architecture before scaffolding. +6. Decide the smallest honest initial crate/workspace boundary that supports the + complete vertical slice in `docs/ARCHITECTURE.md`. +7. Implement, test, and document the vertical slice. Use a branch and keep + changes reversible. Do not merge or publish without the owner's decision. + +Useful local discovery commands include: + +```sh +rg --files /Users/bruce/Development/forest-rs \ + | rg '(^|/)(AGENTS\.md|.*[Tt][Ee][Nn][Ee][Tt].*|ci\.yml|Cargo\.toml|taplo\.toml|clippy\.toml)$' + +rg -n -i 'tenet|no_std|msrv|wasm32v1-none|cargo hack|cargo semver|rustdoc' \ + /Users/bruce/Development/forest-rs +``` + +Prefer narrower searches after locating likely files; the tree contains many +repositories and generated build output may be large. + +## Important unresolved decisions + +- Exact crate/module boundaries after applying local forest-rs conventions. +- The smallest sufficient type representation for owned and borrowed names, + paths, locations, and occurrences. +- Whether query cardinality belongs in static types, builders, execution + methods, or a combination. +- Revision and space identity requirements in `no_std` contexts. +- The division between shared explanation vocabulary and domain-defined + explanation payloads. +- The erased/schema boundary needed by Portolan and agents. +- Which consumer provides the first real adapter after the reference space. + +The implementation agent owns these choices within the mandate and should use +evidence to decide rather than asking the owner to settle routine architecture. + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..7a97f3b --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,244 @@ +# Architectural nucleus + +This document records the shape Addressable is trying to preserve before local +implementation work begins. It is a starting constitution, not a frozen API. + +## 1. Vocabulary + +### Address space + +An address space defines the roots, identity types, views, schemas, revisions, +resolution behavior, and supported navigation relationships of one host-owned +world. An address is never meaningful without its space. + +Type-level space markers should prevent accidental interchange between domains +where possible. Runtime space identity is still needed when multiple instances +of one typed space coexist. + +### Referent and occurrence + +A referent is the semantic thing. An occurrence is one contextual appearance of +that thing reached through a particular parent or incoming edge in a particular +view and revision. + +Equality and deduplication must state which one they mean. Two occurrences may +share a referent. The same locator text in two spaces or revisions may not denote +the same referent. + +### Location, address, locator, and query + +- A `Location` is resolved contextual occurrence information. +- An exact `Address` is canonical, singular, durable data when its domain can + provide such a form. +- A `Locator` is a resolution recipe. It may be relative, policy-bearing, or + capable of reporting more than simple presence or absence. +- A `Query` is an executable expression that can select zero or more located + results. It is not generally valid as a key. +- A `Pinned` reference combines a locator with an expected identity, revision, + or fingerprint and must report rebinding rather than silently accepting it. + +### Endpoint and edge + +An endpoint combines a located owner with a typed facet such as a property, +attribute, port, event, or field. Reading, watching, explaining, and editing +operate on endpoints without requiring the address serialization to encode +value-resolution policy. + +Relationships may be first-class located values. Setout relations and ports, +Layerstack arcs, and Overstory bindings all carry semantics that disappear if +represented only as implicit traversal. + +### Resolved handle + +Arena indices, interned path IDs, generational element IDs, dense graph slots, +and registry-local property IDs are efficient resolved capabilities. They +remain host-owned and may be cached in located values, but are not serialized as +durable world identity unless their host explicitly guarantees it. + +## 2. Multiple views + +One address space may expose several named views over overlapping referents. + +Examples include: + +| Domain | Views | +|---|---| +| Setout | namespace, dependency, relation participation, claim provenance | +| Layerstack | composed stage, authored specs, composition arcs, opinions | +| Overstory | logical tree, template parts, presentation tree, bindings, accessibility | +| Portolan | host subject space, retrieval projections, live result occurrences | + +Only some views form rooted trees with canonical addresses. Other views are +relations traversed by explicit typed axes. Changing view is an operation, not +an undocumented reinterpretation of `/`. + +## 3. Resolution + +Resolution should preserve evidence and distinguish outcomes such as: + +- resolved as expected; +- absent; +- malformed or unsupported locator; +- ambiguous; +- stale revision; +- locator now denotes another referent; +- expected referent moved and was found elsewhere; +- partially resolved; +- view or capability unavailable; +- traversal or work budget exceeded. + +Callers choose rebinding policy explicitly. Convenient APIs may collapse rich +outcomes only where doing so is safe and obvious. + +## 4. Query model + +Queries are typed ASTs before they are strings. A textual language, if added, +parses into the same representation used by Rust builders, agents, UI tools, +and serializers. + +Domains supply axes and node or endpoint tests. Shared query semantics cover: + +- result kind and cardinality (`Exact`, `Optional`, `Many`, or equivalent); +- occurrence versus referent deduplication; +- stable semantic order, traversal order, document order, or explicitly + unordered results; +- whether edges, occurrences, referents, endpoints, or values are returned; +- cycle behavior and visitation identity; +- depth, node, result, and general work budgets; +- diagnostic plans and explanations. + +Shared IR does not imply one evaluator. Setout may execute over dense slots, +Layerstack over namespace and composition indexes, Overstory over retained tree +indexes and binding tables, and Portolan through its retrieval pipeline. + +## 5. Values, provenance, and explanation + +An endpoint identifies what can be read; a value view specifies what is being +asked for. Candidate views include effective, local, authored, default, or all +opinions, but each domain owns the valid set and its precedence rules. + +The common protocol should make it possible for tooling to ask: + +- What is the effective value? +- What alternatives or opinions contributed? +- Why did this one win? +- Which source, rule, claim, layer, style, animation, or binding supplied it? +- What would be affected by changing it? + +Explanation is structured data with addressable subjects and provenance, not +only formatted prose. + +## 6. Live results + +A live query maintains located results across revisions and emits deltas such +as additions, removals, updates, moves, and rebindings. Delta semantics must say +whether identity is by occurrence, referent, or result-entry identity. + +Important law: applying a coherent delta stream to the previous result set must +produce the same observable result as recomputing the query at the new revision. + +Hosts may implement incrementality differently. Addressable standardizes result +and delta meaning rather than forcing one invalidation engine or async runtime. + +## 7. Guarded edits + +Addressed mutation uses typed operations and explicit preconditions: + +- expected referent or endpoint identity; +- expected revision or value fingerprint; +- required capability; +- cardinality requirement; +- optional dry-run and impact analysis. + +Transactions report applied changes, conflicts, rebases, and undo information. +Bulk query-targeted edits must make their selection snapshot and failure policy +explicit. No privileged agent mutation path bypasses normal invariants, +history, invalidation, or explanation. + +## 8. Correspondence between spaces + +Compilation, composition, retrieval, and presentation create partial mappings +between address spaces. These correspondences may be zero-to-one, one-to-many, +many-to-one, revision-sensitive, or lossy. + +They should be first-class and explainable rather than encoded as matching +strings. A useful end-to-end chain might be: + +```text +Layerstack authored spec + -> composed stage endpoint + -> Setout quantity and selected claim + -> Exedra assembly or geometry occurrence + -> Overstory property or presentation fragment + -> Portolan live result and affordance +``` + +Composition of correspondences must preserve ambiguity and provenance. + +## 9. Typed and dynamic boundaries + +Ordinary Rust users should see typed nodes, axes, endpoints, values, and +operations. Erasure belongs at boundaries that genuinely require open-world +behavior: inspectors, persisted query documents, scripting, plugin protocols, +and agent tool schemas. + +The dynamic layer should be schema-backed and capable of recovering type and +capability information. It must not force the typed core to store every value in +one universal enum. + +## 10. Laws worth making executable + +The initial implementation should turn these into tests or conformance cases: + +1. Parsing and formatting canonical exact addresses round-trip. +2. Normalization is idempotent. +3. Joining and relativizing paths obey their documented inverse laws. +4. A pinned locator never silently resolves to a different referent. +5. Occurrence equality does not imply or erase referent equality. +6. Query ordering and deduplication are deterministic when requested. +7. Traversal terminates under declared cycle and budget policy. +8. Live delta replay agrees with full recomputation. +9. Failed guarded transactions have no partial observable effect. +10. Correspondence composition preserves ambiguity and provenance. +11. Typed and dynamic execution agree for representable queries and operations. +12. Durable serialization never leaks runtime-local slots accidentally. + +Property tests and fuzzing should target parsing, normalization, rebasing, +query guards, delta application, and transaction preconditions. + +## 11. Packaging posture + +The repository may become a small workspace, but crate boundaries should follow +dependency and portability boundaries rather than roadmap years. A plausible +shape to evaluate locally is: + +- `addressable`: `no_std` plus `alloc` vocabulary, exact addressing, resolution + contracts, typed query IR, and semantic result types; +- a `std` reference/runtime crate for in-memory indexes, watches, transactions, + and conformance fixtures; +- an erased/schema tooling crate only when a real inspector or agent adapter + demonstrates the boundary; +- consumer adapters living with the consumer unless a dependency-neutral + integration crate is clearly warranted. + +This is not yet a decision. Inspect the old forest-rs tenets and current sibling +practice before fixing the workspace shape. + +## 12. Initial complete vertical slice + +The first executable object space should be deliberately small but exercise the +whole lifecycle: + +1. construct referents shared across multiple occurrences; +2. serialize and resolve exact, relative, and pinned locators; +3. query through at least two named views with explicit cycle and dedup policy; +4. read and explain a typed endpoint; +5. watch a query; +6. apply a guarded transaction; +7. observe coherent deltas; +8. cross a correspondence into a second small space; +9. perform an equivalent operation through the dynamic tooling boundary. + +That prevents later features from discovering that the foundational identity +model was too small while keeping the first implementation finite. + From bc263ed7e2810f9b3f9d25fd0e1bccfe7379b35e Mon Sep 17 00:00:00 2001 From: Bruce Mitchener Date: Mon, 24 Aug 2026 14:58:17 +0700 Subject: [PATCH 2/4] Document Addressable conventions and initial architecture --- docs/CONVENTIONS.md | 79 +++++++++++++ ...01-initial-workspace-and-vertical-slice.md | 107 ++++++++++++++++++ docs/plans/0001-complete-vertical-slice.md | 98 ++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 docs/CONVENTIONS.md create mode 100644 docs/adr/0001-initial-workspace-and-vertical-slice.md create mode 100644 docs/plans/0001-complete-vertical-slice.md diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md new file mode 100644 index 0000000..78cafc6 --- /dev/null +++ b/docs/CONVENTIONS.md @@ -0,0 +1,79 @@ +# Local forest-rs conventions + +This note records the repository conventions discovered before Addressable was +scaffolded. The comparison was performed on 2026-08-24 against `exedra`, +`portolan`, `layerstack`, `understory`, `overstory`, and `inkstone` in the local +forest-rs checkout. Those repositories are references and were not modified. + +## Governing tenets + +The older forest-rs tenets found in sibling `AGENTS.md` files emphasize durable +modularity, incremental work, introspection, explicit behavior, replaceable +subsystems, and calm interfaces. Their definition of done adds strict +formatting, Clippy, rustdoc, public documentation, deterministic tests, and +durable ADRs for public semantic decisions. + +Addressable adopts those tenets without importing a sibling's issue tracker. +This repository currently has neither Beads nor `tk` state, so the initial work +is owned by the durable plan and ADR in `docs/`. + +## Workspace baseline + +- Rust edition 2024. +- Rust 1.88 is the conservative shared MSRV. Newer siblings have moved to 1.92, + but Layerstack, Understory, Portolan, and Inkstone still prove 1.88. +- Cargo resolver 2. +- The intended repository metadata remains `Apache-2.0 OR MIT`, as already + stated in the bootstrap README. This slice does not add or alter license + texts. +- Internal dependencies are centralized in `[workspace.dependencies]`, use + `default-features = false`, and carry versions when publication is intended. +- Initial packages are `publish = false`; publication is an owner decision. +- Production crates have no dev-dependencies. Executable examples are separate + top-level workspace crates. + +## Portability and features + +Foundational vocabulary is `#![no_std]` with `alloc`. A genuinely `std`-owned +runtime is expressed as a separate crate rather than a cosmetic feature. The +core has no production dependencies. CI checks the core on +`x86_64-unknown-none` and `wasm32-unknown-unknown` as well as native targets. + +## Lints and formatting + +The workspace uses the current Linebender-style lint set seen in Exedra and +Portolan, including `unsafe_code = "deny"`, `missing_docs = "warn"`, and the +Cargo metadata lints. The expected local gates are: + +```sh +typos +taplo fmt --check --diff +cargo fmt --all --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps +cargo check -p addressable --target x86_64-unknown-none +cargo check -p addressable --target wasm32-unknown-unknown +cargo +1.88 check --workspace --all-targets --all-features +``` + +CI follows the representative forest-rs matrix: formatting and repository +policy, strict Clippy, tests and doctests, rustdoc, MSRV, and explicit `no_std` +targets. The initial workspace has no need for `cargo-hack` because the core has +no optional feature matrix. + +## Architectural evidence from siblings + +- Layerstack keeps structured paths distinct from interned runtime `PathId`s. +- Setout distinguishes durable fingerprints from evaluation-local handles. +- Exedra uses typed generational handles and documents their runtime-local + meaning. +- Understory uses typed property endpoints and local monotonic revisions. +- Portolan makes live result-entry identity and source capabilities explicit. +- Core crates consistently keep host runtimes, demos, and heavy adapters outside + their foundational package. + +These conventions support, rather than conflict with, the bootstrap +architecture. The only resolved ambiguity is packaging: the first slice uses +three production crates because each represents a demonstrated portability or +open-world dependency boundary, not a roadmap phase. diff --git a/docs/adr/0001-initial-workspace-and-vertical-slice.md b/docs/adr/0001-initial-workspace-and-vertical-slice.md new file mode 100644 index 0000000..288fe33 --- /dev/null +++ b/docs/adr/0001-initial-workspace-and-vertical-slice.md @@ -0,0 +1,107 @@ +# ADR 0001: Initial workspace and complete vertical slice + +- Status: accepted +- Date: 2026-08-24 + +## Context + +Addressable must prove the complete address-resolve-query-watch-patch loop +without turning its durable vocabulary into a host-owned graph database. It +must also keep schema-backed erasure out of the ordinary typed API. + +## Fences + +This `addressable` crate owns portable structured addressing and semantic +contracts; it explicitly does not own object storage, indexing, scheduling, or +domain values. + +This `addressable_reference` crate owns a small scanning object-space host and +its conformance proof; it explicitly does not redefine durable addressing or +dynamic tool schemas. + +This `addressable_tooling` crate owns schema-backed erased inspection and +operation adaptation; it explicitly does not bypass the host's typed guarded +operations. + +The `addressable_tour` example owns the executable narrative; it explicitly +does not provide reusable production behavior. + +## Invariants + +1. Space, referent, occurrence, endpoint, revision, and runtime handle identity + remain distinct types. +2. Exact and relative text parse into structured addresses; strings are never + the resolved representation. +3. A pinned locator never returns ordinary success for a different referent. +4. Query cardinality is visible in the query type, while ordering, + deduplication, cycle behavior, and budgets remain explicit values. +5. Watch deltas replay to the same observable snapshot as full recomputation. +6. Guarded transactions validate atomically and expose dry-run and undo data. +7. Correspondence preserves one-to-many outcomes and evidence. +8. Dynamic reads and writes recover a declared schema and delegate to the same + typed host methods used by Rust callers. +9. Runtime-local handles cannot be formatted or parsed as durable addresses. + +## Options considered + +1. **One crate.** Smallest package count, but `std` execution and dynamic values + would contaminate the portable vocabulary boundary. +2. **Core plus reference runtime.** Preserves portability, but placing erased + tooling in the runtime would make an open-world adapter look like ordinary + host API. +3. **Core, reference runtime, and tooling adapter.** Adds two real dependency + seams and is the chosen design. The executable example remains a separate + non-production workspace package. + +## Decision + +Use the third option with one-way dependencies: + +```text +addressable <- addressable_reference <- addressable_tooling <- addressable_tour +``` + +The reference domain is a small basilica model. One semantic arch referent has +two assembly occurrences. An explicit axis crosses from the assembly view into +a cyclic dependency view. A typed load endpoint has authored/default opinions, +can be read with structured explanation, and can be changed only by a guarded +transaction. A scanning watch recomputes and emits coherent structural deltas. +A second catalog space demonstrates one-to-many correspondence. The tooling +adapter exposes the same load operation through a small declared dynamic +schema. + +The core stays dependency-free and always `no_std + alloc`. The reference and +tooling crates are honestly `std`-dependent. All packages begin unpublished. + +## Cardinality decision + +`One`, `Optional`, and `Many` are marker types on `Query`. Reference execution +methods accept the corresponding query type and return the corresponding +shape. Dynamic tooling may erase that marker only after validating its schema. +This combines compile-time call-site guidance with a representable runtime +contract. + +## Revision and space identity decision + +`SpaceId` is a caller/host-assigned typed `u64`; it does not require a global +allocator or atomics. `Revision` is a local monotonic value meaningful only +with its space. Locations and resolved handles carry both. Durable addresses +carry a space marker at compile time, while locators carry the runtime space +identity required when several instances coexist. + +## Explanation and erasure decision + +The core owns a generic winning-opinion shape. Domains own typed value and +provenance payloads. The tooling crate owns the deliberately small dynamic +value set used by its declared schema; that enum does not enter the typed core +or reference storage. + +## Consequences and extension points + +- Hosts may replace scanning with indexes without changing query or delta + meaning. +- Domain axes, predicates, facets, values, and provenance remain generic. +- A future generic dynamic protocol can replace the reference-specific adapter + once a second real adapter proves its common shape. +- Async runtimes, serialization frameworks, and hash maps are not dependencies + of the nucleus. diff --git a/docs/plans/0001-complete-vertical-slice.md b/docs/plans/0001-complete-vertical-slice.md new file mode 100644 index 0000000..aa7d178 --- /dev/null +++ b/docs/plans/0001-complete-vertical-slice.md @@ -0,0 +1,98 @@ +# Plan 0001: Complete vertical slice + +## Goal + +Deliver one executable, self-explaining object space that exercises exact, +relative, and pinned resolution; multi-view query semantics; typed endpoint +explanation; watch deltas; guarded dry-run and apply; one-to-many +correspondence; and an equivalent schema-backed dynamic operation. + +## Non-goals + +- A production graph database, query parser, async runtime, or incremental + index. +- Consumer-specific Setout, Layerstack, UI, or retrieval adapters. +- Stable publication promises, release artifacts, or licensing changes. +- Performance claims before a consumer workload exists. + +## Public call-site target + +The API should make the semantic choices visible without exposing evaluator +plumbing: + +```rust,ignore +let mut basilica = Basilica::new(SpaceId::new(1)); +let root = Locator::exact( + basilica.id(), + BasilicaView::Assembly, + AbsoluteAddress::parse("/basilica")?, +); + +let query = Query::many(root) + .traverse(BasilicaAxis::Descendants) + .filter(BasilicaPredicate::LoadAtLeast(100)) + .deduplicate(Deduplication::Occurrence) + .order(Ordering::Stable) + .cycles(CyclePolicy::SkipVisited(VisitIdentity::Occurrence)) + .budget(TraversalBudget::new(8, 128, 32, 512)); + +let mut watch = basilica.watch(query.clone())?; +let arch = basilica.query_many(&query)?.items()[0].clone(); +let endpoint = Endpoint::new(arch, Load); +let explained = basilica.read_load(&endpoint)?; + +let edit = SetLoad::new(endpoint, 80, Guard::at( + explained.subject(), + basilica.revision(), + explained.value(), +)); +let preview = basilica.transact(Transaction::dry_run([edit.clone()]))?; +let applied = basilica.transact(Transaction::apply([edit]))?; +let delta = watch.poll(&basilica)?; +``` + +The dynamic adapter constructs the same typed endpoint, guard, and transaction +after schema validation. + +## Steps + +1. Scaffold the workspace, core, reference, tooling, and tour packages. +2. Implement names, addresses, locators, typed identities, locations, + endpoints, resolution outcomes, query IR, explained values, deltas, + transaction vocabulary, and correspondence. +3. Turn the architectural laws that are representable in the nucleus into unit + tests. +4. Implement the basilica and catalog spaces with scanning resolution and query + execution. +5. Implement typed load explanation, atomic guarded edits, watch recomputation, + and delta generation. +6. Implement the schema-backed dynamic adapter solely through typed host calls. +7. Build the tour and README narrative from the same public contracts. +8. Add CI and run all local gates, including Rust 1.88 and `no_std` targets. + +## Risks and controls + +- **Generic API inflation:** keep host traits out until a second host proves + them; make the semantic result types generic instead. +- **Mirage completeness:** every lifecycle claim must appear in the executable + tour and an assertion-backed test. +- **Silent rebinding:** pinned resolution has dedicated rebound and moved + outcomes plus a regression test. +- **Partial mutation:** validate every operation against one snapshot before + applying any change; test a failing multi-operation transaction. +- **Delta drift:** replay every emitted delta and compare it with full query + recomputation. +- **Dependency creep:** use only `core`, `alloc`, and `std` in this slice. + +## Validation checklist + +- [ ] `typos` +- [ ] `taplo fmt --check --diff` +- [ ] `cargo fmt --all --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-features` +- [ ] warning-denied rustdoc +- [ ] `x86_64-unknown-none` core check +- [ ] `wasm32-unknown-unknown` core check +- [ ] Rust 1.88 workspace check +- [ ] executable tour run From c5763863626c55e9201e13f5c8144e9b57616855 Mon Sep 17 00:00:00 2001 From: Bruce Mitchener Date: Mon, 24 Aug 2026 15:08:08 +0700 Subject: [PATCH 3/4] Implement the complete Addressable vertical slice --- .gitignore | 1 + Cargo.lock | 31 + Cargo.toml | 81 ++ clippy.toml | 1 + crates/addressable/Cargo.toml | 14 + crates/addressable/src/address.rs | 725 +++++++++++++++++ crates/addressable/src/correspondence.rs | 172 ++++ crates/addressable/src/edit.rs | 156 ++++ crates/addressable/src/explain.rs | 105 +++ crates/addressable/src/identity.rs | 394 +++++++++ crates/addressable/src/lib.rs | 54 ++ crates/addressable/src/live.rs | 627 +++++++++++++++ crates/addressable/src/query.rs | 387 +++++++++ crates/addressable/src/resolution.rs | 134 ++++ crates/addressable_reference/Cargo.toml | 17 + crates/addressable_reference/src/catalog.rs | 249 ++++++ crates/addressable_reference/src/lib.rs | 46 ++ crates/addressable_reference/src/model.rs | 265 +++++++ crates/addressable_reference/src/mutation.rs | 432 ++++++++++ crates/addressable_reference/src/space.rs | 795 +++++++++++++++++++ crates/addressable_reference/src/watch.rs | 156 ++++ crates/addressable_tooling/Cargo.toml | 18 + crates/addressable_tooling/src/lib.rs | 539 +++++++++++++ examples/addressable_tour/Cargo.toml | 17 + examples/addressable_tour/src/main.rs | 237 ++++++ taplo.toml | 25 + 26 files changed, 5678 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 clippy.toml create mode 100644 crates/addressable/Cargo.toml create mode 100644 crates/addressable/src/address.rs create mode 100644 crates/addressable/src/correspondence.rs create mode 100644 crates/addressable/src/edit.rs create mode 100644 crates/addressable/src/explain.rs create mode 100644 crates/addressable/src/identity.rs create mode 100644 crates/addressable/src/lib.rs create mode 100644 crates/addressable/src/live.rs create mode 100644 crates/addressable/src/query.rs create mode 100644 crates/addressable/src/resolution.rs create mode 100644 crates/addressable_reference/Cargo.toml create mode 100644 crates/addressable_reference/src/catalog.rs create mode 100644 crates/addressable_reference/src/lib.rs create mode 100644 crates/addressable_reference/src/model.rs create mode 100644 crates/addressable_reference/src/mutation.rs create mode 100644 crates/addressable_reference/src/space.rs create mode 100644 crates/addressable_reference/src/watch.rs create mode 100644 crates/addressable_tooling/Cargo.toml create mode 100644 crates/addressable_tooling/src/lib.rs create mode 100644 examples/addressable_tour/Cargo.toml create mode 100644 examples/addressable_tour/src/main.rs create mode 100644 taplo.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..99dd7aa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,31 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addressable" +version = "0.1.0" + +[[package]] +name = "addressable_reference" +version = "0.1.0" +dependencies = [ + "addressable", +] + +[[package]] +name = "addressable_tooling" +version = "0.1.0" +dependencies = [ + "addressable", + "addressable_reference", +] + +[[package]] +name = "addressable_tour" +version = "0.1.0" +dependencies = [ + "addressable", + "addressable_reference", + "addressable_tooling", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..00e3a6e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,81 @@ +[workspace] +resolver = "2" +members = [ + "crates/addressable", + "crates/addressable_reference", + "crates/addressable_tooling", + "examples/addressable_tour", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "Apache-2.0 OR MIT" +repository = "https://github.com/forest-rs/addressable" + +[workspace.dependencies] +addressable = { path = "crates/addressable", version = "0.1.0" } +addressable_reference = { path = "crates/addressable_reference", version = "0.1.0" } +addressable_tooling = { path = "crates/addressable_tooling", version = "0.1.0" } + +[workspace.lints] +# LINEBENDER LINT SET - Cargo.toml - v8 +# See https://linebender.org/wiki/canonical-lints/ +rust.keyword_idents_2024 = "forbid" +rust.non_ascii_idents = "forbid" +rust.non_local_definitions = "forbid" +rust.unsafe_op_in_unsafe_fn = "forbid" + +rust.elided_lifetimes_in_paths = "warn" +rust.missing_debug_implementations = "warn" +rust.missing_docs = "warn" +rust.trivial_numeric_casts = "warn" +rust.unnameable_types = "warn" +rust.unreachable_pub = "warn" +rust.unused_import_braces = "warn" +rust.unused_lifetimes = "warn" +rust.unused_macro_rules = "warn" +rust.unused_qualifications = "warn" + +rust.unsafe_code = "deny" + +clippy.too_many_arguments = "allow" + +clippy.allow_attributes_without_reason = "warn" +clippy.cast_possible_truncation = "warn" +clippy.cast_possible_wrap = "warn" +clippy.collection_is_never_read = "warn" +clippy.dbg_macro = "warn" +clippy.debug_assert_with_mut_call = "warn" +clippy.default_trait_access = "warn" +clippy.doc_markdown = "warn" +clippy.fn_to_numeric_cast_any = "warn" +clippy.infinite_loop = "warn" +clippy.large_stack_arrays = "warn" +clippy.mismatching_type_param_order = "warn" +clippy.missing_assert_message = "warn" +clippy.missing_fields_in_debug = "warn" +clippy.same_functions_in_if_condition = "warn" +clippy.semicolon_if_nothing_returned = "warn" +clippy.should_panic_without_expect = "warn" +clippy.todo = "warn" +clippy.unseparated_literal_suffix = "warn" +clippy.use_self = "warn" + +clippy.cargo_common_metadata = "warn" +clippy.negative_feature_names = "warn" +clippy.redundant_feature_names = "warn" +clippy.wildcard_dependencies = "warn" +# END LINEBENDER LINT SET + +# Prefer `#[expect(...)]` so stale suppressions are visible. +clippy.allow_attributes = "warn" + +[profile.ci] +inherits = "dev" +debug = 0 +strip = "debuginfo" + +[profile.ci.package."*"] +debug-assertions = true diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..5845d64 --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +# Clippy configuration for Addressable. diff --git a/crates/addressable/Cargo.toml b/crates/addressable/Cargo.toml new file mode 100644 index 0000000..5b29ea0 --- /dev/null +++ b/crates/addressable/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "addressable" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Typed no_std vocabulary for addressable structured object spaces" +keywords = ["address", "graph", "no-std", "query"] +categories = ["data-structures", "no-std"] +publish = false + +[lints] +workspace = true diff --git a/crates/addressable/src/address.rs b/crates/addressable/src/address.rs new file mode 100644 index 0000000..29bf65e --- /dev/null +++ b/crates/addressable/src/address.rs @@ -0,0 +1,725 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Validated names, structured addresses, locators, and pinned references. + +use alloc::{boxed::Box, string::ToString, vec::Vec}; +use core::{ + cmp::Ordering, + fmt, + hash::{Hash, Hasher}, + marker::PhantomData, + str::FromStr, +}; + +use crate::{Revision, SpaceId}; + +/// One validated address segment. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Name(Box); + +impl Name { + /// Validates and owns one address segment. + pub fn new(value: impl AsRef) -> Result { + let value = value.as_ref(); + if value.is_empty() { + return Err(NameError::Empty); + } + if value == "." || value == ".." { + return Err(NameError::Reserved); + } + if value.contains('/') { + return Err(NameError::ContainsSlash); + } + if value.contains('\0') { + return Err(NameError::ContainsNul); + } + Ok(Self(Box::from(value))) + } + + /// Returns the validated segment text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Failure while validating one [`Name`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NameError { + /// A segment cannot be empty. + Empty, + /// `.` and `..` are reserved for relative navigation. + Reserved, + /// A segment cannot contain the `/` separator. + ContainsSlash, + /// A segment cannot contain a NUL character. + ContainsNul, +} + +/// A normalized, structured absolute address in typed space `S`. +pub struct AbsoluteAddress { + segments: Box<[Name]>, + marker: PhantomData S>, +} + +impl Clone for AbsoluteAddress { + fn clone(&self) -> Self { + Self { + segments: self.segments.clone(), + marker: PhantomData, + } + } +} + +impl fmt::Debug for AbsoluteAddress { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AbsoluteAddress") + .field(&self.to_string()) + .finish() + } +} + +impl PartialEq for AbsoluteAddress { + fn eq(&self, other: &Self) -> bool { + self.segments == other.segments + } +} + +impl Eq for AbsoluteAddress {} + +impl PartialOrd for AbsoluteAddress { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for AbsoluteAddress { + fn cmp(&self, other: &Self) -> Ordering { + self.segments.cmp(&other.segments) + } +} + +impl Hash for AbsoluteAddress { + fn hash(&self, state: &mut H) { + self.segments.hash(state); + } +} + +impl AbsoluteAddress { + /// Returns the root address (`/`). + #[must_use] + pub fn root() -> Self { + Self { + segments: Box::default(), + marker: PhantomData, + } + } + + /// Builds an address from already validated segments. + #[must_use] + pub fn from_names(names: impl IntoIterator) -> Self { + Self { + segments: names.into_iter().collect::>().into_boxed_slice(), + marker: PhantomData, + } + } + + /// Parses and normalizes an absolute textual address. + /// + /// `.` components are removed and `..` components cancel a preceding + /// segment. Traversal above the root is rejected. + pub fn parse(text: &str) -> Result { + if !text.starts_with('/') { + return Err(AddressError::NotAbsolute); + } + if text == "/" { + return Ok(Self::root()); + } + + let mut names = Vec::new(); + for component in text[1..].split('/') { + match component { + "" => return Err(AddressError::EmptySegment), + "." => {} + ".." => { + names.pop().ok_or(AddressError::TraversesAboveRoot)?; + } + value => names.push(Name::new(value).map_err(AddressError::InvalidName)?), + } + } + Ok(Self::from_names(names)) + } + + /// Returns the number of name segments. + #[must_use] + pub fn depth(&self) -> usize { + self.segments.len() + } + + /// Returns the validated segments. + #[must_use] + pub fn segments(&self) -> &[Name] { + &self.segments + } + + /// Returns the parent, or `None` for the root. + #[must_use] + pub fn parent(&self) -> Option { + let (_, parents) = self.segments.split_last()?; + Some(Self::from_names(parents.iter().cloned())) + } + + /// Resolves a structured relative address against this address. + pub fn join(&self, relative: &RelativeAddress) -> Result { + let keep = self + .segments + .len() + .checked_sub( + usize::try_from(relative.upward) + .expect("u32 relative depth must fit this platform's usize"), + ) + .ok_or(AddressError::TraversesAboveRoot)?; + let names = self.segments[..keep] + .iter() + .chain(relative.segments.iter()) + .cloned(); + Ok(Self::from_names(names)) + } + + /// Computes a normalized relative address from `base` to `self`. + #[must_use] + pub fn relative_to(&self, base: &Self) -> RelativeAddress { + let common = self + .segments + .iter() + .zip(base.segments.iter()) + .take_while(|(left, right)| left == right) + .count(); + let upward = u32::try_from(base.segments.len() - common) + .expect("address depth exceeds representable relative depth"); + RelativeAddress { + upward, + segments: self.segments[common..].to_vec().into_boxed_slice(), + marker: PhantomData, + } + } +} + +impl fmt::Display for AbsoluteAddress { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.segments.is_empty() { + return formatter.write_str("/"); + } + for name in &self.segments { + write!(formatter, "/{name}")?; + } + Ok(()) + } +} + +/// A normalized structured address relative to an explicit base. +pub struct RelativeAddress { + upward: u32, + segments: Box<[Name]>, + marker: PhantomData S>, +} + +impl Clone for RelativeAddress { + fn clone(&self) -> Self { + Self { + upward: self.upward, + segments: self.segments.clone(), + marker: PhantomData, + } + } +} + +impl fmt::Debug for RelativeAddress { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("RelativeAddress") + .field(&self.to_string()) + .finish() + } +} + +impl PartialEq for RelativeAddress { + fn eq(&self, other: &Self) -> bool { + self.upward == other.upward && self.segments == other.segments + } +} + +impl Eq for RelativeAddress {} + +impl PartialOrd for RelativeAddress { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for RelativeAddress { + fn cmp(&self, other: &Self) -> Ordering { + self.upward + .cmp(&other.upward) + .then_with(|| self.segments.cmp(&other.segments)) + } +} + +impl Hash for RelativeAddress { + fn hash(&self, state: &mut H) { + self.upward.hash(state); + self.segments.hash(state); + } +} + +impl RelativeAddress { + /// Returns the identity relative address (`.`). + #[must_use] + pub fn current() -> Self { + Self { + upward: 0, + segments: Box::default(), + marker: PhantomData, + } + } + + /// Parses and normalizes relative text. + /// + /// Parent components cancel preceding child components before increasing + /// the stored upward count. + pub fn parse(text: &str) -> Result { + if text.starts_with('/') { + return Err(AddressError::NotRelative); + } + if text.is_empty() || text == "." { + return Ok(Self::current()); + } + + let mut upward = 0_u32; + let mut names = Vec::new(); + for component in text.split('/') { + match component { + "" => return Err(AddressError::EmptySegment), + "." => {} + ".." => { + if names.pop().is_none() { + upward = upward.checked_add(1).ok_or(AddressError::DepthOverflow)?; + } + } + value => names.push(Name::new(value).map_err(AddressError::InvalidName)?), + } + } + Ok(Self { + upward, + segments: names.into_boxed_slice(), + marker: PhantomData, + }) + } + + /// Returns the number of parents traversed before descending. + #[must_use] + pub const fn upward(&self) -> u32 { + self.upward + } + + /// Returns the child segments after parent traversal. + #[must_use] + pub fn segments(&self) -> &[Name] { + &self.segments + } +} + +impl fmt::Display for RelativeAddress { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.upward == 0 && self.segments.is_empty() { + return formatter.write_str("."); + } + let mut needs_separator = false; + for _ in 0..self.upward { + if needs_separator { + formatter.write_str("/")?; + } + formatter.write_str("..")?; + needs_separator = true; + } + for name in &self.segments { + if needs_separator { + formatter.write_str("/")?; + } + name.fmt(formatter)?; + needs_separator = true; + } + Ok(()) + } +} + +/// Failure while parsing, normalizing, or joining an address. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AddressError { + /// An absolute address did not begin with `/`. + NotAbsolute, + /// A relative address began with `/`. + NotRelative, + /// Two separators produced an empty segment. + EmptySegment, + /// One name segment was invalid. + InvalidName(NameError), + /// Normalization or joining attempted to traverse above the root. + TraversesAboveRoot, + /// Relative parent depth exceeded `u32`. + DepthOverflow, +} + +/// The structured recipe carried by a [`Locator`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum LocatorKind { + /// A canonical exact address. + Exact(AbsoluteAddress), + /// A relative address with an explicit exact base. + Relative { + /// Exact base against which `path` is interpreted. + base: AbsoluteAddress, + /// Normalized path relative to `base`. + path: RelativeAddress, + }, +} + +/// A view-qualified resolution recipe in one runtime space instance. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Locator { + space: SpaceId, + view: V, + kind: LocatorKind, +} + +impl Locator { + /// Creates an exact locator. + #[must_use] + pub const fn exact(space: SpaceId, view: V, address: AbsoluteAddress) -> Self { + Self { + space, + view, + kind: LocatorKind::Exact(address), + } + } + + /// Creates a relative locator with an explicit exact base. + #[must_use] + pub const fn relative( + space: SpaceId, + view: V, + base: AbsoluteAddress, + path: RelativeAddress, + ) -> Self { + Self { + space, + view, + kind: LocatorKind::Relative { base, path }, + } + } + + /// Returns the runtime space instance. + #[must_use] + pub const fn space(&self) -> SpaceId { + self.space + } + + /// Returns the named view. + #[must_use] + pub const fn view(&self) -> &V { + &self.view + } + + /// Returns the structured locator recipe. + #[must_use] + pub const fn kind(&self) -> &LocatorKind { + &self.kind + } + + /// Materializes this recipe as an exact address. + pub fn to_absolute(&self) -> Result, AddressError> { + match &self.kind { + LocatorKind::Exact(address) => Ok(address.clone()), + LocatorKind::Relative { base, path } => base.join(path), + } + } +} + +impl fmt::Display for Locator +where + V: fmt::Display, +{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let view = self.view.to_string(); + write!(formatter, "{}:{}:{}:", self.space.get(), view.len(), view)?; + match &self.kind { + LocatorKind::Exact(address) => write!(formatter, "E:{address}"), + LocatorKind::Relative { base, path } => { + let base = base.to_string(); + write!(formatter, "R:{}:{}:{path}", base.len(), base) + } + } + } +} + +impl FromStr for Locator +where + V: FromStr, +{ + type Err = LocatorParseError; + + fn from_str(text: &str) -> Result { + let (space, rest) = text + .split_once(':') + .ok_or(LocatorParseError::InvalidSyntax)?; + let space = space + .parse::() + .map_err(|_| LocatorParseError::InvalidSpace)?; + let (view, rest) = take_length_prefixed(rest).ok_or(LocatorParseError::InvalidSyntax)?; + let view = view.parse().map_err(LocatorParseError::InvalidView)?; + if let Some(address) = rest.strip_prefix(":E:") { + return AbsoluteAddress::parse(address) + .map(|address| Self::exact(SpaceId::new(space), view, address)) + .map_err(LocatorParseError::InvalidAddress); + } + let rest = rest + .strip_prefix(":R:") + .ok_or(LocatorParseError::InvalidSyntax)?; + let (base, path) = take_length_prefixed(rest).ok_or(LocatorParseError::InvalidSyntax)?; + let path = path + .strip_prefix(':') + .ok_or(LocatorParseError::InvalidSyntax)?; + let base = AbsoluteAddress::parse(base).map_err(LocatorParseError::InvalidAddress)?; + let path = RelativeAddress::parse(path).map_err(LocatorParseError::InvalidAddress)?; + Ok(Self::relative(SpaceId::new(space), view, base, path)) + } +} + +/// Failure to parse a canonical [`Locator`] document. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LocatorParseError { + /// Length prefixes or structural separators were malformed. + InvalidSyntax, + /// Runtime space identity was not a `u64`. + InvalidSpace, + /// The domain view name was invalid. + InvalidView(E), + /// An exact, base, or relative address was invalid. + InvalidAddress(AddressError), +} + +/// A locator pinned to expected semantic identity and revision. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Pinned { + locator: Locator, + expected_referent: I, + expected_revision: Revision, +} + +impl Pinned { + /// Pins a locator to identity observed at `expected_revision`. + #[must_use] + pub const fn new( + locator: Locator, + expected_referent: I, + expected_revision: Revision, + ) -> Self { + Self { + locator, + expected_referent, + expected_revision, + } + } + + /// Returns the underlying locator. + #[must_use] + pub const fn locator(&self) -> &Locator { + &self.locator + } + + /// Returns expected semantic identity. + #[must_use] + pub const fn expected_referent(&self) -> &I { + &self.expected_referent + } + + /// Returns the revision at which the pin was established. + #[must_use] + pub const fn expected_revision(&self) -> Revision { + self.expected_revision + } +} + +impl fmt::Display for Pinned +where + V: fmt::Display, + I: fmt::Display, +{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let locator = self.locator.to_string(); + let identity = self.expected_referent.to_string(); + write!( + formatter, + "{}:{}{}:{}:{}:{}", + locator.len(), + locator, + identity.len(), + identity, + self.expected_revision.space().get(), + self.expected_revision.get() + ) + } +} + +impl FromStr for Pinned +where + V: FromStr, + I: FromStr, +{ + type Err = PinnedParseError; + + fn from_str(text: &str) -> Result { + let (locator, rest) = take_length_prefixed(text).ok_or(PinnedParseError::InvalidSyntax)?; + let locator = locator.parse().map_err(PinnedParseError::InvalidLocator)?; + let (identity, revision) = + take_length_prefixed(rest).ok_or(PinnedParseError::InvalidSyntax)?; + let revision = revision + .strip_prefix(':') + .ok_or(PinnedParseError::InvalidSyntax)?; + let (revision_space, revision) = revision + .split_once(':') + .ok_or(PinnedParseError::InvalidSyntax)?; + let identity = identity + .parse() + .map_err(PinnedParseError::InvalidIdentity)?; + let revision_space = revision_space + .parse::() + .map_err(|_| PinnedParseError::InvalidRevision)?; + let revision = revision + .parse::() + .map_err(|_| PinnedParseError::InvalidRevision)?; + Ok(Self::new( + locator, + identity, + Revision::new(SpaceId::new(revision_space), revision), + )) + } +} + +/// Failure to parse a canonical [`Pinned`] document. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PinnedParseError { + /// Length prefixes or structural separators were malformed. + InvalidSyntax, + /// The embedded locator was invalid. + InvalidLocator(LocatorParseError), + /// Expected semantic identity could not be recovered. + InvalidIdentity(I), + /// Expected revision was not a `u64`. + InvalidRevision, +} + +fn take_length_prefixed(text: &str) -> Option<(&str, &str)> { + let (length, rest) = text.split_once(':')?; + let length = length.parse::().ok()?; + let value = rest.get(..length)?; + Some((value, &rest[length..])) +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use super::{AbsoluteAddress, AddressError, Locator, Pinned, RelativeAddress}; + use crate::{Revision, SpaceId}; + + #[derive(Debug, PartialEq, Eq)] + struct Space; + + #[test] + fn exact_address_parse_and_format_round_trip() { + let path = AbsoluteAddress::::parse("/basilica/./nave/side/../arch") + .expect("address should normalize"); + assert_eq!(path.to_string(), "/basilica/nave/arch"); + assert_eq!( + AbsoluteAddress::::parse(&path.to_string()).expect("canonical text parses"), + path + ); + } + + #[test] + fn normalization_is_idempotent() { + let once = RelativeAddress::::parse("chapel/../nave/./arch") + .expect("relative address should normalize"); + let twice = RelativeAddress::::parse(&once.to_string()) + .expect("canonical relative address parses"); + assert_eq!(once, twice); + assert_eq!(once.to_string(), "nave/arch"); + } + + #[test] + fn join_and_relativize_are_inverse() { + let base = AbsoluteAddress::::parse("/basilica/nave").expect("valid base"); + let target = + AbsoluteAddress::::parse("/basilica/transept/arch").expect("valid target"); + let relative = target.relative_to(&base); + assert_eq!(relative.to_string(), "../transept/arch"); + assert_eq!(base.join(&relative).expect("relative joins"), target); + } + + #[test] + fn traversal_above_root_is_rejected() { + assert_eq!( + AbsoluteAddress::::parse("/../outside"), + Err(AddressError::TraversesAboveRoot) + ); + } + + #[test] + fn exact_relative_and_pinned_locator_documents_round_trip() { + let exact = Locator::::exact( + SpaceId::new(7), + 2, + AbsoluteAddress::parse("/basilica/nave/a:|#~rch") + .expect("delimiter characters belong to the address data"), + ); + let exact_text = exact.to_string(); + assert_eq!( + exact_text + .parse::>() + .expect("locator parses"), + exact + ); + + let relative = Locator::::relative( + SpaceId::new(7), + 2, + AbsoluteAddress::parse("/basilica/nave").expect("valid base"), + RelativeAddress::parse("../transept/arch").expect("valid relative path"), + ); + let relative_text = relative.to_string(); + assert_eq!( + relative_text + .parse::>() + .expect("relative locator parses"), + relative + ); + + let pinned = Pinned::new(relative, 42_u64, Revision::new(SpaceId::new(7), 9)); + let pinned_text = pinned.to_string(); + assert_eq!( + pinned_text + .parse::>() + .expect("pinned locator parses"), + pinned + ); + } +} diff --git a/crates/addressable/src/correspondence.rs b/crates/addressable/src/correspondence.rs new file mode 100644 index 0000000..9e962f2 --- /dev/null +++ b/crates/addressable/src/correspondence.rs @@ -0,0 +1,172 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Partial, evidence-bearing correspondence between address spaces. + +use alloc::{boxed::Box, vec::Vec}; + +/// One correspondence target and the evidence for that mapping. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CorrespondenceTarget { + target: T, + provenance: P, +} + +impl CorrespondenceTarget { + /// Creates one evidence-bearing target. + #[must_use] + pub const fn new(target: T, provenance: P) -> Self { + Self { target, provenance } + } + + /// Returns the target in the destination space. + #[must_use] + pub const fn target(&self) -> &T { + &self.target + } + + /// Returns evidence for the mapping. + #[must_use] + pub const fn provenance(&self) -> &P { + &self.provenance + } +} + +/// A partial, possibly one-to-many mapping from one source value. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Correspondence { + source: F, + targets: Box<[CorrespondenceTarget]>, +} + +impl Correspondence { + /// Creates a correspondence, including an empty partial result. + #[must_use] + pub fn new(source: F, targets: impl IntoIterator>) -> Self { + Self { + source, + targets: targets.into_iter().collect::>().into_boxed_slice(), + } + } + + /// Returns the source value. + #[must_use] + pub const fn source(&self) -> &F { + &self.source + } + + /// Returns every destination and its evidence. + #[must_use] + pub fn targets(&self) -> &[CorrespondenceTarget] { + &self.targets + } + + /// Returns whether the source has more than one destination. + #[must_use] + pub fn is_ambiguous(&self) -> bool { + self.targets.len() > 1 + } + + /// Composes this mapping with a second mapping while retaining evidence + /// from both legs and preserving multiplicity. + /// + /// The callback cannot replace the source it receives with an unrelated + /// source value. + /// + /// ```compile_fail + /// use addressable::{Correspondence, CorrespondenceTarget}; + /// + /// let first = Correspondence::new( + /// "arch", + /// [CorrespondenceTarget::new("north", "assembly")], + /// ); + /// let _: Result<_, ()> = first.compose(|_| { + /// Ok(Correspondence::new( + /// "unrelated", + /// [CorrespondenceTarget::new(1_u8, "catalog")], + /// )) + /// }); + /// ``` + pub fn compose( + &self, + mut next: impl FnMut(&T) -> Result, + ) -> Result>, E> + where + F: Clone, + P: Clone, + C: IntoIterator>, + { + let mut composed = Vec::new(); + for first in &self.targets { + for target in next(&first.target)? { + composed.push(CorrespondenceTarget::new( + target.target, + ComposedEvidence::new(first.provenance.clone(), target.provenance), + )); + } + } + Ok(Correspondence::new(self.source.clone(), composed)) + } +} + +/// Evidence retained from both legs of correspondence composition. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ComposedEvidence { + first: A, + second: B, +} + +impl ComposedEvidence { + /// Pairs evidence from two mapping legs. + #[must_use] + pub const fn new(first: A, second: B) -> Self { + Self { first, second } + } + + /// Returns evidence from the first leg. + #[must_use] + pub const fn first(&self) -> &A { + &self.first + } + + /// Returns evidence from the second leg. + #[must_use] + pub const fn second(&self) -> &B { + &self.second + } +} + +#[cfg(test)] +mod tests { + use super::{Correspondence, CorrespondenceTarget}; + + #[test] + fn composition_preserves_ambiguity_and_both_provenance_legs() { + let first = Correspondence::new( + "arch", + [ + CorrespondenceTarget::new("north", "assembly:north"), + CorrespondenceTarget::new("south", "assembly:south"), + ], + ); + let composed = first + .compose::<_, _, (), _>(|occurrence| { + Ok([CorrespondenceTarget::new( + if *occurrence == "north" { 1 } else { 2 }, + "catalog:result", + )]) + }) + .expect("composition succeeds"); + + assert!(composed.is_ambiguous()); + assert_eq!(composed.targets().len(), 2); + assert_eq!( + composed.targets()[0].provenance().first(), + &"assembly:north" + ); + assert_eq!( + composed.targets()[0].provenance().second(), + &"catalog:result" + ); + } +} diff --git a/crates/addressable/src/edit.rs b/crates/addressable/src/edit.rs new file mode 100644 index 0000000..bd1290b --- /dev/null +++ b/crates/addressable/src/edit.rs @@ -0,0 +1,156 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Guard and transaction vocabulary for addressed mutation. + +use alloc::vec::Vec; + +use crate::Revision; + +/// Preconditions required before applying an addressed mutation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Guard { + expected_referent: I, + expected_revision: Revision, + expected_value: V, + required_capability: C, +} + +impl Guard { + /// Creates an identity, revision, and value guard with no extra capability token. + #[must_use] + pub const fn at( + expected_referent: I, + expected_revision: Revision, + expected_value: V, + ) -> Self { + Self::new(expected_referent, expected_revision, expected_value, ()) + } +} + +impl Guard { + /// Creates a complete guarded-mutation precondition. + #[must_use] + pub const fn new( + expected_referent: I, + expected_revision: Revision, + expected_value: V, + required_capability: C, + ) -> Self { + Self { + expected_referent, + expected_revision, + expected_value, + required_capability, + } + } + + /// Returns expected semantic referent identity. + #[must_use] + pub const fn expected_referent(&self) -> &I { + &self.expected_referent + } + + /// Returns the revision at which selection and reading occurred. + #[must_use] + pub const fn expected_revision(&self) -> Revision { + self.expected_revision + } + + /// Returns the value fingerprint or typed value observed by the caller. + #[must_use] + pub const fn expected_value(&self) -> &V { + &self.expected_value + } + + /// Returns the required host capability. + #[must_use] + pub const fn required_capability(&self) -> &C { + &self.required_capability + } +} + +/// Whether a transaction is previewed or committed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransactionMode { + /// Validate and report impact without changing state. + DryRun, + /// Validate atomically and then commit. + Apply, +} + +/// Behavior when one operation in a transaction conflicts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FailurePolicy { + /// No operation is observable unless every operation validates. + Atomic, +} + +/// A snapshot-scoped collection of typed operations. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Transaction { + selection_revision: Revision, + mode: TransactionMode, + failure_policy: FailurePolicy, + operations: Vec, +} + +impl Transaction { + /// Creates an atomic dry run against one selection revision. + #[must_use] + pub fn dry_run( + selection_revision: Revision, + operations: impl IntoIterator, + ) -> Self { + Self::new(selection_revision, TransactionMode::DryRun, operations) + } + + /// Creates an atomic applying transaction against one selection revision. + #[must_use] + pub fn apply(selection_revision: Revision, operations: impl IntoIterator) -> Self { + Self::new(selection_revision, TransactionMode::Apply, operations) + } + + fn new( + selection_revision: Revision, + mode: TransactionMode, + operations: impl IntoIterator, + ) -> Self { + Self { + selection_revision, + mode, + failure_policy: FailurePolicy::Atomic, + operations: operations.into_iter().collect(), + } + } + + /// Returns the revision against which targets were selected. + #[must_use] + pub const fn selection_revision(&self) -> Revision { + self.selection_revision + } + + /// Returns preview or apply mode. + #[must_use] + pub const fn mode(&self) -> TransactionMode { + self.mode + } + + /// Returns all-or-nothing failure policy. + #[must_use] + pub const fn failure_policy(&self) -> FailurePolicy { + self.failure_policy + } + + /// Returns typed operations in caller order. + #[must_use] + pub fn operations(&self) -> &[O] { + &self.operations + } + + /// Consumes the transaction and returns its operations. + #[must_use] + pub fn into_operations(self) -> Vec { + self.operations + } +} diff --git a/crates/addressable/src/explain.rs b/crates/addressable/src/explain.rs new file mode 100644 index 0000000..7a85fc4 --- /dev/null +++ b/crates/addressable/src/explain.rs @@ -0,0 +1,105 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Generic typed value opinions and winning explanations. + +use alloc::{boxed::Box, vec::Vec}; + +/// One typed candidate value and its domain-owned provenance. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Opinion { + value: T, + provenance: P, +} + +impl Opinion { + /// Creates one value opinion. + #[must_use] + pub const fn new(value: T, provenance: P) -> Self { + Self { value, provenance } + } + + /// Returns the candidate value. + #[must_use] + pub const fn value(&self) -> &T { + &self.value + } + + /// Returns domain-owned provenance. + #[must_use] + pub const fn provenance(&self) -> &P { + &self.provenance + } +} + +/// A winning typed value, alternatives, provenance, and domain-owned reason. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Explained { + subject: S, + opinions: Box<[Opinion]>, + winner: usize, + reason: R, +} + +impl Explained { + /// Creates an explanation and validates its winner index. + pub fn new( + subject: S, + opinions: impl IntoIterator>, + winner: usize, + reason: R, + ) -> Result { + let opinions = opinions.into_iter().collect::>().into_boxed_slice(); + if opinions.is_empty() { + return Err(ExplainError::NoOpinions); + } + if winner >= opinions.len() { + return Err(ExplainError::WinnerOutOfBounds); + } + Ok(Self { + subject, + opinions, + winner, + reason, + }) + } + + /// Returns the explained semantic subject. + #[must_use] + pub const fn subject(&self) -> &S { + &self.subject + } + + /// Returns the effective winning value. + #[must_use] + pub fn value(&self) -> &T { + self.opinions[self.winner].value() + } + + /// Returns every typed opinion in domain-defined strength order. + #[must_use] + pub fn opinions(&self) -> &[Opinion] { + &self.opinions + } + + /// Returns the winning opinion index. + #[must_use] + pub const fn winner(&self) -> usize { + self.winner + } + + /// Returns the domain-owned explanation reason. + #[must_use] + pub const fn reason(&self) -> &R { + &self.reason + } +} + +/// Invalid construction of an [`Explained`] value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExplainError { + /// At least one opinion is required. + NoOpinions, + /// The winner index did not identify an opinion. + WinnerOutOfBounds, +} diff --git a/crates/addressable/src/identity.rs b/crates/addressable/src/identity.rs new file mode 100644 index 0000000..951df2c --- /dev/null +++ b/crates/addressable/src/identity.rs @@ -0,0 +1,394 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Typed space, location, endpoint, and resolved-handle identities. + +use core::{ + cmp::Ordering, + fmt, + hash::{Hash, Hasher}, + marker::PhantomData, +}; + +use crate::AbsoluteAddress; + +/// Runtime identity for one instance of a typed address space. +/// +/// The marker `S` prevents ids from unrelated domain types from being mixed. +/// Values are assigned by the host; Addressable does not require a global id +/// generator or atomics. +pub struct SpaceId { + raw: u64, + marker: PhantomData S>, +} + +impl Copy for SpaceId {} + +impl Clone for SpaceId { + fn clone(&self) -> Self { + *self + } +} + +impl fmt::Debug for SpaceId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("SpaceId").field(&self.raw).finish() + } +} + +impl PartialEq for SpaceId { + fn eq(&self, other: &Self) -> bool { + self.raw == other.raw + } +} + +impl Eq for SpaceId {} + +impl PartialOrd for SpaceId { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SpaceId { + fn cmp(&self, other: &Self) -> Ordering { + self.raw.cmp(&other.raw) + } +} + +impl Hash for SpaceId { + fn hash(&self, state: &mut H) { + self.raw.hash(state); + } +} + +impl SpaceId { + /// Creates a typed space id from a host-assigned value. + #[must_use] + pub const fn new(raw: u64) -> Self { + Self { + raw, + marker: PhantomData, + } + } + + /// Returns the host-assigned value. + #[must_use] + pub const fn get(self) -> u64 { + self.raw + } +} + +/// Monotonic revision scoped to one typed address-space instance. +/// +/// Keeping the [`SpaceId`] inside the value prevents equal numeric counters +/// from unrelated space instances from comparing as the same revision. +pub struct Revision { + space: SpaceId, + sequence: u64, +} + +impl Copy for Revision {} + +impl Clone for Revision { + fn clone(&self) -> Self { + *self + } +} + +impl fmt::Debug for Revision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Revision") + .field("space", &self.space) + .field("sequence", &self.sequence) + .finish() + } +} + +impl PartialEq for Revision { + fn eq(&self, other: &Self) -> bool { + self.space == other.space && self.sequence == other.sequence + } +} + +impl Eq for Revision {} + +impl PartialOrd for Revision { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Revision { + fn cmp(&self, other: &Self) -> Ordering { + self.space + .cmp(&other.space) + .then_with(|| self.sequence.cmp(&other.sequence)) + } +} + +impl Hash for Revision { + fn hash(&self, state: &mut H) { + self.space.hash(state); + self.sequence.hash(state); + } +} + +impl Revision { + /// Creates the initial revision for one space instance. + #[must_use] + pub const fn initial(space: SpaceId) -> Self { + Self::new(space, 0) + } + + /// Creates a revision from a space id and host-owned monotonic sequence. + #[must_use] + pub const fn new(space: SpaceId, sequence: u64) -> Self { + Self { space, sequence } + } + + /// Returns the owning space instance. + #[must_use] + pub const fn space(self) -> SpaceId { + self.space + } + + /// Returns the host-owned monotonic sequence. + #[must_use] + pub const fn get(self) -> u64 { + self.sequence + } + + /// Returns the next revision in the same space, wrapping only after `u64::MAX`. + #[must_use] + pub const fn next(self) -> Self { + Self::new(self.space, self.sequence.wrapping_add(1)) + } +} + +/// Resolved contextual information for one occurrence. +/// +/// `R` is durable referent identity and `O` is contextual occurrence identity. +/// The two are intentionally stored separately even when a domain happens to +/// use the same representation for both. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Location { + view: V, + revision: Revision, + referent: R, + occurrence: O, + address: AbsoluteAddress, +} + +impl Location { + /// Creates resolved occurrence context. + #[must_use] + pub const fn new( + view: V, + revision: Revision, + referent: R, + occurrence: O, + address: AbsoluteAddress, + ) -> Self { + Self { + view, + revision, + referent, + occurrence, + address, + } + } + + /// Returns the runtime address-space identity. + #[must_use] + pub const fn space(&self) -> SpaceId { + self.revision.space() + } + + /// Returns the named domain view. + #[must_use] + pub const fn view(&self) -> &V { + &self.view + } + + /// Returns the revision against which this occurrence was resolved. + #[must_use] + pub const fn revision(&self) -> Revision { + self.revision + } + + /// Returns durable semantic referent identity. + #[must_use] + pub const fn referent(&self) -> &R { + &self.referent + } + + /// Returns contextual occurrence identity. + #[must_use] + pub const fn occurrence(&self) -> &O { + &self.occurrence + } + + /// Returns the canonical exact address of this occurrence in its view. + #[must_use] + pub const fn address(&self) -> &AbsoluteAddress { + &self.address + } + + /// Decomposes the location into its typed parts. + #[must_use] + pub fn into_parts(self) -> (V, Revision, R, O, AbsoluteAddress) { + ( + self.view, + self.revision, + self.referent, + self.occurrence, + self.address, + ) + } +} + +/// A resolved referent value paired with the context through which it was found. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Located { + referent: T, + location: L, +} + +impl Located { + /// Pairs a referent value with its resolved location. + #[must_use] + pub const fn new(referent: T, location: L) -> Self { + Self { referent, location } + } + + /// Returns the semantic referent value. + #[must_use] + pub const fn referent(&self) -> &T { + &self.referent + } + + /// Returns the occurrence context. + #[must_use] + pub const fn location(&self) -> &L { + &self.location + } + + /// Decomposes the pair. + #[must_use] + pub fn into_parts(self) -> (T, L) { + (self.referent, self.location) + } +} + +/// A typed addressable facet on a located owner. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Endpoint { + owner: L, + facet: F, +} + +impl Endpoint { + /// Creates a typed endpoint. + #[must_use] + pub const fn new(owner: L, facet: F) -> Self { + Self { owner, facet } + } + + /// Returns the located owner. + #[must_use] + pub const fn owner(&self) -> &L { + &self.owner + } + + /// Returns the typed facet. + #[must_use] + pub const fn facet(&self) -> &F { + &self.facet + } + + /// Decomposes the endpoint. + #[must_use] + pub fn into_parts(self) -> (L, F) { + (self.owner, self.facet) + } +} + +/// Efficient host-local capability resolved at one revision. +/// +/// `H` may be an arena slot, generational handle, interned id, or another +/// runtime accelerator. This wrapper carries context but intentionally has no +/// textual serialization API. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ResolvedHandle { + revision: Revision, + handle: H, +} + +impl ResolvedHandle { + /// Creates a revision-scoped resolved handle. + #[must_use] + pub const fn new(revision: Revision, handle: H) -> Self { + Self { revision, handle } + } + + /// Returns the owning space instance. + #[must_use] + pub const fn space(&self) -> SpaceId { + self.revision.space() + } + + /// Returns the revision at which the handle was resolved. + #[must_use] + pub const fn revision(&self) -> Revision { + self.revision + } + + /// Returns the host-local handle. + #[must_use] + pub const fn handle(&self) -> &H { + &self.handle + } +} + +#[cfg(test)] +mod tests { + use super::{Location, Revision, SpaceId}; + use crate::AbsoluteAddress; + + #[derive(Debug, PartialEq, Eq)] + struct Space; + + #[test] + fn occurrence_equality_does_not_erase_referent_equality() { + let address_a = AbsoluteAddress::::parse("/root/a").expect("valid path"); + let address_b = AbsoluteAddress::::parse("/root/b").expect("valid path"); + let a = Location::new( + 0_u8, + Revision::initial(SpaceId::new(1)), + 7_u64, + 1_u64, + address_a, + ); + let b = Location::new( + 0_u8, + Revision::initial(SpaceId::new(1)), + 7_u64, + 2_u64, + address_b, + ); + + assert_eq!( + a.referent(), + b.referent(), + "the semantic referent is shared" + ); + assert_ne!( + a.occurrence(), + b.occurrence(), + "occurrences remain distinct" + ); + assert_ne!(a, b, "location equality includes occurrence context"); + } +} diff --git a/crates/addressable/src/lib.rs b/crates/addressable/src/lib.rs new file mode 100644 index 0000000..28bcf8f --- /dev/null +++ b/crates/addressable/src/lib.rs @@ -0,0 +1,54 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Typed vocabulary for addressable structured object spaces. +//! +//! `addressable` keeps durable addresses, contextual locations, semantic +//! identities, runtime handles, query policy, live deltas, guarded edits, and +//! correspondence distinct. It deliberately owns no storage engine or domain +//! value enum. +//! +//! The crate is always `no_std` and uses `alloc` for owned structured values. +//! +//! ``` +//! use addressable::{AbsoluteAddress, RelativeAddress}; +//! +//! enum BasilicaSpace {} +//! +//! let nave = AbsoluteAddress::::parse("/basilica/nave")?; +//! let arch = RelativeAddress::::parse("../transept/arch")?; +//! let target = nave.join(&arch)?; +//! assert_eq!(target.to_string(), "/basilica/transept/arch"); +//! # Ok::<(), addressable::AddressError>(()) +//! ``` + +#![no_std] + +extern crate alloc; + +mod address; +mod correspondence; +mod edit; +mod explain; +mod identity; +mod live; +mod query; +mod resolution; + +pub use address::{ + AbsoluteAddress, AddressError, Locator, LocatorKind, LocatorParseError, Name, NameError, + Pinned, PinnedParseError, RelativeAddress, +}; +pub use correspondence::{ComposedEvidence, Correspondence, CorrespondenceTarget}; +pub use edit::{FailurePolicy, Guard, Transaction, TransactionMode}; +pub use explain::{ExplainError, Explained, Opinion}; +pub use identity::{Endpoint, Located, Location, ResolvedHandle, Revision, SpaceId}; +pub use live::{ + DeltaError, LiveQueryId, QueryChange, QueryDelta, QuerySnapshot, ResultEntry, ResultIdentity, +}; +pub use query::{ + Cardinality, CardinalityKind, CyclePolicy, Deduplication, Many, One, Optional, Query, + QueryError, QueryResults, QuerySemantics, QueryStats, QueryStep, ResultOrdering, + TraversalBudget, VisitIdentity, +}; +pub use resolution::{BudgetDimension, BudgetExceeded, PartialReason, Resolution}; diff --git a/crates/addressable/src/live.rs b/crates/addressable/src/live.rs new file mode 100644 index 0000000..2e98c54 --- /dev/null +++ b/crates/addressable/src/live.rs @@ -0,0 +1,627 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Coherent query snapshots and replayable structural deltas. + +use alloc::{boxed::Box, vec::Vec}; +use core::{ + cmp::Ordering, + fmt, + hash::{Hash, Hasher}, + marker::PhantomData, +}; + +use crate::Revision; + +/// Host-assigned identity of one live query within a typed space instance. +/// +/// The id is interpreted together with the space carried by a [`Revision`]. +/// Addressable does not prescribe allocation or require atomics. +pub struct LiveQueryId { + raw: u64, + marker: PhantomData S>, +} + +impl Copy for LiveQueryId {} + +impl Clone for LiveQueryId { + fn clone(&self) -> Self { + *self + } +} + +impl fmt::Debug for LiveQueryId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("LiveQueryId") + .field(&self.raw) + .finish() + } +} + +impl PartialEq for LiveQueryId { + fn eq(&self, other: &Self) -> bool { + self.raw == other.raw + } +} + +impl Eq for LiveQueryId {} + +impl PartialOrd for LiveQueryId { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for LiveQueryId { + fn cmp(&self, other: &Self) -> Ordering { + self.raw.cmp(&other.raw) + } +} + +impl Hash for LiveQueryId { + fn hash(&self, state: &mut H) { + self.raw.hash(state); + } +} + +impl LiveQueryId { + /// Creates a live-query id from a host-assigned value. + #[must_use] + pub const fn new(raw: u64) -> Self { + Self { + raw, + marker: PhantomData, + } + } + + /// Returns the host-assigned value. + #[must_use] + pub const fn get(self) -> u64 { + self.raw + } +} + +/// Identity used to track live result entries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResultIdentity { + /// Contextual occurrence identity. + Occurrence, + /// Semantic referent identity. + Referent, + /// A host-assigned result-entry identity. + Entry, +} + +/// One stable live-query result entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResultEntry { + key: K, + value: T, +} + +impl ResultEntry { + /// Creates a keyed result entry. + #[must_use] + pub const fn new(key: K, value: T) -> Self { + Self { key, value } + } + + /// Returns stable result identity. + #[must_use] + pub const fn key(&self) -> &K { + &self.key + } + + /// Returns the located result value. + #[must_use] + pub const fn value(&self) -> &T { + &self.value + } +} + +/// A complete live-query result at one revision. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuerySnapshot { + live_query: LiveQueryId, + revision: Revision, + identity: ResultIdentity, + entries: Vec>, +} + +impl QuerySnapshot { + /// Creates a complete snapshot. + #[must_use] + pub fn new( + live_query: LiveQueryId, + revision: Revision, + identity: ResultIdentity, + entries: impl IntoIterator>, + ) -> Self { + Self { + live_query, + revision, + identity, + entries: entries.into_iter().collect(), + } + } + + /// Returns the live query whose result this snapshot represents. + #[must_use] + pub const fn live_query(&self) -> LiveQueryId { + self.live_query + } + + /// Returns the object-space revision. + #[must_use] + pub const fn revision(&self) -> Revision { + self.revision + } + + /// Returns the declared live-entry identity. + #[must_use] + pub const fn identity(&self) -> ResultIdentity { + self.identity + } + + /// Returns ordered result entries. + #[must_use] + pub fn entries(&self) -> &[ResultEntry] { + &self.entries + } +} + +impl QuerySnapshot +where + K: Clone + Eq, + T: Clone + Eq, +{ + /// Applies one delta atomically. + /// + /// On error, `self` is unchanged. + pub fn apply(&mut self, delta: &QueryDelta) -> Result<(), DeltaError> { + if self.live_query != delta.live_query { + return Err(DeltaError::LiveQueryMismatch); + } + if delta.from_revision.space() != delta.to_revision.space() { + return Err(DeltaError::SpaceMismatch); + } + if self.revision != delta.from_revision { + return Err(DeltaError::WrongRevision { + expected: self.revision, + actual: delta.from_revision, + }); + } + if self.identity != delta.identity { + return Err(DeltaError::IdentityMismatch); + } + + let mut entries = self.entries.clone(); + for change in &delta.changes { + match change { + QueryChange::Added { index, entry } => { + if *index > entries.len() { + return Err(DeltaError::IndexOutOfBounds { index: *index }); + } + if entries.iter().any(|existing| existing.key == entry.key) { + return Err(DeltaError::DuplicateKey); + } + entries.insert(*index, entry.clone()); + } + QueryChange::Removed { index, entry } => { + let Some(existing) = entries.get(*index) else { + return Err(DeltaError::IndexOutOfBounds { index: *index }); + }; + if existing != entry { + return Err(DeltaError::EntryMismatch); + } + entries.remove(*index); + } + QueryChange::Updated { index, old, new } => { + let Some(existing) = entries.get_mut(*index) else { + return Err(DeltaError::IndexOutOfBounds { index: *index }); + }; + if existing != old || old.key != new.key { + return Err(DeltaError::EntryMismatch); + } + *existing = new.clone(); + } + QueryChange::Moved { key, from, to } => { + let Some(existing) = entries.get(*from) else { + return Err(DeltaError::IndexOutOfBounds { index: *from }); + }; + if &existing.key != key { + return Err(DeltaError::KeyMismatch); + } + let entry = entries.remove(*from); + if *to > entries.len() { + return Err(DeltaError::IndexOutOfBounds { index: *to }); + } + entries.insert(*to, entry); + } + QueryChange::Rebound { index, old, new } => { + let Some(existing) = entries.get_mut(*index) else { + return Err(DeltaError::IndexOutOfBounds { index: *index }); + }; + if existing != old || old.key != new.key { + return Err(DeltaError::EntryMismatch); + } + *existing = new.clone(); + } + } + } + + self.entries = entries; + self.revision = delta.to_revision; + Ok(()) + } +} + +/// One replayable structural change in a live query. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum QueryChange { + /// Insert a new entry at an ordered index. + Added { + /// New index. + index: usize, + /// New entry. + entry: ResultEntry, + }, + /// Remove an entry from an ordered index. + Removed { + /// Previous index. + index: usize, + /// Previous entry, used to validate replay. + entry: ResultEntry, + }, + /// Replace observable data while retaining result identity. + Updated { + /// Stable index at this point in the delta stream. + index: usize, + /// Previous entry. + old: ResultEntry, + /// Replacement entry with the same key. + new: ResultEntry, + }, + /// Move a stable entry in ordered results. + Moved { + /// Stable entry key. + key: K, + /// Previous index at this point in the delta stream. + from: usize, + /// New index. + to: usize, + }, + /// Keep result-entry identity while reporting a changed referent binding. + Rebound { + /// Stable index at this point in the delta stream. + index: usize, + /// Previous binding. + old: ResultEntry, + /// New binding with the same result-entry key. + new: ResultEntry, + }, +} + +/// A coherent revision-to-revision live-query delta. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QueryDelta { + live_query: LiveQueryId, + from_revision: Revision, + to_revision: Revision, + identity: ResultIdentity, + changes: Box<[QueryChange]>, +} + +impl QueryDelta { + /// Creates a delta from already ordered structural changes. + #[must_use] + pub fn new( + live_query: LiveQueryId, + from_revision: Revision, + to_revision: Revision, + identity: ResultIdentity, + changes: impl IntoIterator>, + ) -> Self { + Self { + live_query, + from_revision, + to_revision, + identity, + changes: changes.into_iter().collect::>().into_boxed_slice(), + } + } + + /// Returns the live query whose transition this delta describes. + #[must_use] + pub const fn live_query(&self) -> LiveQueryId { + self.live_query + } + + /// Returns the previous revision. + #[must_use] + pub const fn from_revision(&self) -> Revision { + self.from_revision + } + + /// Returns the new revision. + #[must_use] + pub const fn to_revision(&self) -> Revision { + self.to_revision + } + + /// Returns live-entry identity semantics. + #[must_use] + pub const fn identity(&self) -> ResultIdentity { + self.identity + } + + /// Returns replay-ordered changes. + #[must_use] + pub fn changes(&self) -> &[QueryChange] { + &self.changes + } +} + +impl QueryDelta +where + K: Clone + Eq, + T: Clone + Eq, +{ + /// Computes a deterministic delta between complete snapshots. + pub fn between( + before: &QuerySnapshot, + after: &QuerySnapshot, + ) -> Result> { + if before.live_query != after.live_query { + return Err(DeltaError::LiveQueryMismatch); + } + if before.revision.space() != after.revision.space() { + return Err(DeltaError::SpaceMismatch); + } + if before.identity != after.identity { + return Err(DeltaError::IdentityMismatch); + } + ensure_unique(&before.entries)?; + ensure_unique(&after.entries)?; + + let mut working = before.entries.clone(); + let mut changes = Vec::new(); + + for index in (0..working.len()).rev() { + if !after + .entries + .iter() + .any(|entry| entry.key == working[index].key) + { + let entry = working.remove(index); + changes.push(QueryChange::Removed { index, entry }); + } + } + + for (index, target) in after.entries.iter().enumerate() { + if working + .get(index) + .is_some_and(|entry| entry.key == target.key) + { + if working[index] != *target { + let old = working[index].clone(); + working[index] = target.clone(); + changes.push(QueryChange::Updated { + index, + old, + new: target.clone(), + }); + } + continue; + } + + if let Some(from) = working.iter().position(|entry| entry.key == target.key) { + let entry = working.remove(from); + working.insert(index, entry); + changes.push(QueryChange::Moved { + key: target.key.clone(), + from, + to: index, + }); + if working[index] != *target { + let old = working[index].clone(); + working[index] = target.clone(); + changes.push(QueryChange::Updated { + index, + old, + new: target.clone(), + }); + } + } else { + working.insert(index, target.clone()); + changes.push(QueryChange::Added { + index, + entry: target.clone(), + }); + } + } + + debug_assert!( + working == after.entries, + "generated structural changes must reproduce the target entries" + ); + Ok(Self::new( + before.live_query, + before.revision, + after.revision, + before.identity, + changes, + )) + } +} + +fn ensure_unique(entries: &[ResultEntry]) -> Result<(), DeltaError> { + for (index, entry) in entries.iter().enumerate() { + if entries[..index] + .iter() + .any(|previous| previous.key == entry.key) + { + return Err(DeltaError::DuplicateKey); + } + } + Ok(()) +} + +/// Failure to construct or atomically replay a live delta. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeltaError { + /// Snapshot and delta belong to different live queries. + LiveQueryMismatch, + /// The delta attempts to cross runtime space instances. + SpaceMismatch, + /// The delta starts at a different revision from the snapshot. + WrongRevision { + /// Snapshot revision. + expected: Revision, + /// Delta's declared previous revision. + actual: Revision, + }, + /// Snapshot and delta use different live-entry identities. + IdentityMismatch, + /// A snapshot contains duplicate stable keys. + DuplicateKey, + /// A structural change named an unavailable index. + IndexOutOfBounds { + /// Invalid index. + index: usize, + }, + /// Replay evidence did not match the current entry. + EntryMismatch, + /// A move's stable key did not match its source index. + KeyMismatch, +} + +#[cfg(test)] +mod tests { + use alloc::vec; + + use super::{DeltaError, LiveQueryId, QueryDelta, QuerySnapshot, ResultEntry, ResultIdentity}; + use crate::{Revision, SpaceId}; + + #[derive(Clone, Debug, PartialEq, Eq)] + enum TestSpace {} + + #[test] + fn replay_rejects_another_space_or_live_query_atomically() { + let space = SpaceId::::new(1); + let second_space = SpaceId::::new(2); + let stream = LiveQueryId::::new(10); + let mut snapshot = QuerySnapshot::new( + stream, + Revision::new(space, 4), + ResultIdentity::Entry, + [ResultEntry::new(1_u8, "one")], + ); + let original = snapshot.clone(); + + let other_stream = QueryDelta::new( + LiveQueryId::new(11), + Revision::new(space, 4), + Revision::new(space, 5), + ResultIdentity::Entry, + [], + ); + assert_eq!( + snapshot.apply(&other_stream), + Err(DeltaError::LiveQueryMismatch) + ); + assert_eq!(snapshot, original); + + let other_space = QueryDelta::new( + stream, + Revision::new(second_space, 4), + Revision::new(second_space, 5), + ResultIdentity::Entry, + [], + ); + assert!(matches!( + snapshot.apply(&other_space), + Err(DeltaError::WrongRevision { .. }) + )); + assert_eq!(snapshot, original); + + let crossing_space = QueryDelta::new( + stream, + Revision::new(space, 4), + Revision::new(second_space, 5), + ResultIdentity::Entry, + [], + ); + assert_eq!( + snapshot.apply(&crossing_space), + Err(DeltaError::SpaceMismatch) + ); + assert_eq!(snapshot, original); + } + + #[test] + fn delta_replay_agrees_with_full_recomputation() { + let space = SpaceId::::new(1); + let stream = LiveQueryId::new(1); + let before = QuerySnapshot::new( + stream, + Revision::new(space, 4), + ResultIdentity::Occurrence, + [ + ResultEntry::new(1_u8, "north"), + ResultEntry::new(2_u8, "south"), + ResultEntry::new(3_u8, "altar"), + ], + ); + let after = QuerySnapshot::new( + stream, + Revision::new(space, 5), + ResultIdentity::Occurrence, + [ + ResultEntry::new(2_u8, "south-updated"), + ResultEntry::new(4_u8, "choir"), + ResultEntry::new(1_u8, "north"), + ], + ); + let delta = QueryDelta::between(&before, &after).expect("snapshots have unique keys"); + let mut replayed = before; + replayed.apply(&delta).expect("generated delta must replay"); + assert_eq!(replayed, after); + } + + #[test] + fn failed_replay_has_no_partial_effect() { + let space = SpaceId::::new(1); + let stream = LiveQueryId::new(1); + let mut snapshot = QuerySnapshot::new( + stream, + Revision::new(space, 1), + ResultIdentity::Entry, + [ResultEntry::new(1_u8, "one")], + ); + let original = snapshot.clone(); + let delta = QueryDelta::new( + stream, + Revision::new(space, 1), + Revision::new(space, 2), + ResultIdentity::Entry, + vec![ + super::QueryChange::Added { + index: 1, + entry: ResultEntry::new(2_u8, "two"), + }, + super::QueryChange::Removed { + index: 8, + entry: ResultEntry::new(9_u8, "missing"), + }, + ], + ); + + assert_eq!( + snapshot.apply(&delta), + Err(DeltaError::IndexOutOfBounds { index: 8 }) + ); + assert_eq!(snapshot, original); + } +} diff --git a/crates/addressable/src/query.rs b/crates/addressable/src/query.rs new file mode 100644 index 0000000..d9a09e8 --- /dev/null +++ b/crates/addressable/src/query.rs @@ -0,0 +1,387 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Typed query IR and explicit execution policy. + +use alloc::{boxed::Box, vec::Vec}; +use core::marker::PhantomData; + +use crate::BudgetExceeded; + +/// Marker for a query that must return exactly one result. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct One; + +/// Marker for a query that may return zero or one result. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Optional; + +/// Marker for a query that may return several results. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Many; + +/// Runtime representation of the static cardinality marker. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CardinalityKind { + /// Exactly one result is required. + One, + /// Zero or one result is allowed. + Optional, + /// Any result count within the result budget is allowed. + Many, +} + +mod sealed { + #[expect( + unnameable_types, + reason = "the unnameable supertrait is what seals Cardinality" + )] + pub trait Sealed {} +} + +/// Closed set of supported static query cardinalities. +/// +/// This trait is sealed so every marker has a defined [`CardinalityKind`] and +/// hosts can exhaustively interpret the result contract. +pub trait Cardinality: sealed::Sealed { + /// Runtime representation of this static result shape. + const KIND: CardinalityKind; +} + +impl sealed::Sealed for One {} +impl Cardinality for One { + const KIND: CardinalityKind = CardinalityKind::One; +} + +impl sealed::Sealed for Optional {} +impl Cardinality for Optional { + const KIND: CardinalityKind = CardinalityKind::Optional; +} + +impl sealed::Sealed for Many {} +impl Cardinality for Many { + const KIND: CardinalityKind = CardinalityKind::Many; +} + +/// One host-defined query operation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum QueryStep { + /// Traverse one typed domain axis. + Traverse(A), + /// Retain values matching one typed domain predicate. + Filter(P), +} + +/// Ordering promised by query execution. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResultOrdering { + /// Preserve deterministic traversal order. + Traversal, + /// Sort by the host's stable semantic ordering. + Stable, + /// The caller does not rely on result order. + Unordered, +} + +/// Result deduplication identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Deduplication { + /// Preserve every result entry, including repeats. + None, + /// Deduplicate by contextual occurrence identity. + Occurrence, + /// Deduplicate by semantic referent identity. + Referent, +} + +/// Identity used to detect revisitation during cyclic traversal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VisitIdentity { + /// A distinct occurrence is a distinct visit. + Occurrence, + /// Any occurrence of an already visited referent counts as revisitation. + Referent, +} + +/// Declared behavior when traversal encounters a cycle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CyclePolicy { + /// Stop and return a cycle error. + Error, + /// Skip nodes already visited under the selected identity. + SkipVisited(VisitIdentity), +} + +/// Explicit upper bounds for query execution. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TraversalBudget { + /// Maximum axis-traversal depth. + pub max_depth: u32, + /// Maximum number of nodes visited. + pub max_nodes: u32, + /// Maximum number of results produced. + pub max_results: u32, + /// Maximum host-defined work units. + pub max_work: u32, +} + +impl TraversalBudget { + /// Creates a complete set of traversal limits. + #[must_use] + pub const fn new(max_depth: u32, max_nodes: u32, max_results: u32, max_work: u32) -> Self { + Self { + max_depth, + max_nodes, + max_results, + max_work, + } + } +} + +impl Default for TraversalBudget { + fn default() -> Self { + Self::new(64, 16_384, 4_096, 65_536) + } +} + +/// Shared policy that every query carries explicitly. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct QuerySemantics { + /// Promised result ordering. + pub ordering: ResultOrdering, + /// Result identity used for deduplication. + pub deduplication: Deduplication, + /// Behavior on cyclic traversal. + pub cycle_policy: CyclePolicy, + /// Hard traversal limits. + pub budget: TraversalBudget, +} + +impl Default for QuerySemantics { + fn default() -> Self { + Self { + ordering: ResultOrdering::Traversal, + deduplication: Deduplication::Occurrence, + cycle_policy: CyclePolicy::Error, + budget: TraversalBudget::default(), + } + } +} + +/// A typed query abstract syntax tree. +/// +/// `L`, `A`, and `P` are the host's locator, axis, and predicate types. `C` +/// records result cardinality at the call site. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Query { + start: L, + steps: Vec>, + semantics: QuerySemantics, + cardinality: PhantomData C>, +} + +impl Query { + /// Starts a query that may return several results. + #[must_use] + pub fn many(start: L) -> Self { + Self::new(start) + } +} + +impl Query { + /// Starts a query that must return exactly one result. + #[must_use] + pub fn one(start: L) -> Self { + Self::new(start) + } +} + +impl Query { + /// Starts a query that may return zero or one result. + #[must_use] + pub fn optional(start: L) -> Self { + Self::new(start) + } +} + +impl Query { + fn new(start: L) -> Self { + Self { + start, + steps: Vec::new(), + semantics: QuerySemantics::default(), + cardinality: PhantomData, + } + } + + /// Appends one typed traversal step. + #[must_use] + pub fn traverse(mut self, axis: A) -> Self { + self.steps.push(QueryStep::Traverse(axis)); + self + } + + /// Appends one typed predicate step. + #[must_use] + pub fn filter(mut self, predicate: P) -> Self { + self.steps.push(QueryStep::Filter(predicate)); + self + } + + /// Selects the result deduplication identity. + #[must_use] + pub const fn deduplicate(mut self, deduplication: Deduplication) -> Self { + self.semantics.deduplication = deduplication; + self + } + + /// Selects result ordering. + #[must_use] + pub const fn order(mut self, ordering: ResultOrdering) -> Self { + self.semantics.ordering = ordering; + self + } + + /// Selects cycle behavior. + #[must_use] + pub const fn cycles(mut self, cycle_policy: CyclePolicy) -> Self { + self.semantics.cycle_policy = cycle_policy; + self + } + + /// Sets hard traversal limits. + #[must_use] + pub const fn budget(mut self, budget: TraversalBudget) -> Self { + self.semantics.budget = budget; + self + } + + /// Changes only the static cardinality marker. + /// + /// Cardinality is closed over Addressable's three supported result shapes. + /// + /// ```compile_fail + /// use addressable::{Many, Query}; + /// + /// struct Unchecked; + /// + /// let query: Query<(), (), (), Many> = Query::many(()); + /// let _ = query.with_cardinality::(); + /// ``` + #[must_use] + pub fn with_cardinality(self) -> Query { + Query { + start: self.start, + steps: self.steps, + semantics: self.semantics, + cardinality: PhantomData, + } + } + + /// Returns the runtime form of the static cardinality marker. + #[must_use] + pub const fn cardinality(&self) -> CardinalityKind { + C::KIND + } + + /// Returns the start locator. + #[must_use] + pub const fn start(&self) -> &L { + &self.start + } + + /// Returns query steps in execution order. + #[must_use] + pub fn steps(&self) -> &[QueryStep] { + &self.steps + } + + /// Returns shared execution policy. + #[must_use] + pub const fn semantics(&self) -> QuerySemantics { + self.semantics + } +} + +/// Host-independent query failure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum QueryError { + /// The start locator did not resolve to an ordinary result. + StartDidNotResolve, + /// Static cardinality was not satisfied by the result count. + Cardinality { + /// Required result shape. + expected: CardinalityKind, + /// Actual result count. + actual: usize, + }, + /// Traversal encountered a cycle under [`CyclePolicy::Error`]. + Cycle, + /// Execution exhausted a declared budget. + BudgetExceeded(BudgetExceeded), + /// The query requested an axis or predicate unavailable in this view. + UnsupportedStep, +} + +/// Measured work performed by a query execution. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct QueryStats { + /// Nodes inspected, including nodes rejected by predicates. + pub visited_nodes: u32, + /// Host-defined work units charged. + pub work_units: u32, + /// Maximum traversal depth reached. + pub max_depth_reached: u32, +} + +/// Query items paired with measured execution work. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QueryResults { + items: Box<[T]>, + stats: QueryStats, +} + +impl QueryResults { + /// Creates a measured result collection. + #[must_use] + pub fn new(items: impl IntoIterator, stats: QueryStats) -> Self { + Self { + items: items.into_iter().collect::>().into_boxed_slice(), + stats, + } + } + + /// Returns result items. + #[must_use] + pub fn items(&self) -> &[T] { + &self.items + } + + /// Returns measured query work. + #[must_use] + pub const fn stats(&self) -> QueryStats { + self.stats + } + + /// Decomposes the result. + #[must_use] + pub fn into_parts(self) -> (Box<[T]>, QueryStats) { + (self.items, self.stats) + } +} + +#[cfg(test)] +mod tests { + use super::{CardinalityKind, Many, One, Optional, Query}; + + #[test] + fn static_cardinality_has_one_runtime_kind() { + let one: Query<(), (), (), One> = Query::one(()); + let optional: Query<(), (), (), Optional> = Query::optional(()); + let many: Query<(), (), (), Many> = Query::many(()); + + assert_eq!(one.cardinality(), CardinalityKind::One); + assert_eq!(optional.cardinality(), CardinalityKind::Optional); + assert_eq!(many.cardinality(), CardinalityKind::Many); + } +} diff --git a/crates/addressable/src/resolution.rs b/crates/addressable/src/resolution.rs new file mode 100644 index 0000000..6bac878 --- /dev/null +++ b/crates/addressable/src/resolution.rs @@ -0,0 +1,134 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Rich resolution outcomes. + +use alloc::{boxed::Box, string::String}; + +use crate::Revision; + +/// A resolution outcome that preserves absence, ambiguity, staleness, movement, +/// and rebinding. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Resolution { + /// The locator resolved without violating its policy. + Resolved(T), + /// Nothing currently occupies the requested location. + Absent, + /// The locator admitted several valid occurrences. + Ambiguous(Box<[T]>), + /// The required view or operation is unsupported by this host. + UnsupportedLocator, + /// Resolution was attempted against a newer or otherwise incompatible revision. + StaleRevision { + /// Revision required by the caller. + expected: Revision, + /// Current host revision. + actual: Revision, + }, + /// The locator now denotes a different semantic referent. + Rebound { + /// Referent identity pinned by the caller. + expected: I, + /// Referent identity currently at the locator. + actual: I, + /// Current resolved occurrence, returned as evidence rather than success. + resolved: T, + }, + /// The expected referent was found at a different exact address. + Moved { + /// Address carried by the locator. + from: A, + /// Current address of the expected referent. + to: A, + /// Occurrence at the new address. + resolved: T, + }, + /// Resolution returned usable partial results with an explicit reason. + Partial { + /// Resolved portion. + resolved: Box<[T]>, + /// Why resolution stopped. + reason: PartialReason, + }, + /// The host does not expose a required capability in this view. + CapabilityUnavailable(String), + /// A declared traversal budget was exhausted. + BudgetExceeded(BudgetExceeded), +} + +impl Resolution { + /// Returns the ordinary resolved value, if and only if no exceptional + /// resolution state occurred. + #[must_use] + pub fn resolved(self) -> Option { + match self { + Self::Resolved(value) => Some(value), + _ => None, + } + } +} + +/// Why otherwise valid resolution is partial. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum PartialReason { + /// A required subspace was unavailable. + SubspaceUnavailable, + /// Some but not all candidates were authorized. + CapabilityUnavailable, + /// Work stopped at a declared budget. + BudgetExceeded(BudgetExceeded), +} + +/// The budget dimension that stopped work. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BudgetDimension { + /// Maximum traversal depth. + Depth, + /// Maximum visited nodes. + Nodes, + /// Maximum returned results. + Results, + /// Maximum host-defined work units. + Work, +} + +/// Evidence that one declared traversal budget was exceeded. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BudgetExceeded { + dimension: BudgetDimension, + limit: u32, + observed: u32, +} + +impl BudgetExceeded { + /// Records a budget limit and the first observed value beyond it. + #[must_use] + pub const fn new(dimension: BudgetDimension, limit: u32, observed: u32) -> Self { + Self { + dimension, + limit, + observed, + } + } + + /// Returns the exhausted budget dimension. + #[must_use] + pub const fn dimension(self) -> BudgetDimension { + self.dimension + } + + /// Returns the declared limit. + #[must_use] + pub const fn limit(self) -> u32 { + self.limit + } + + /// Returns the observed value that exceeded the limit. + #[must_use] + pub const fn observed(self) -> u32 { + self.observed + } +} diff --git a/crates/addressable_reference/Cargo.toml b/crates/addressable_reference/Cargo.toml new file mode 100644 index 0000000..e863cf1 --- /dev/null +++ b/crates/addressable_reference/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "addressable_reference" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Scanning reference object spaces for Addressable conformance" +keywords = ["address", "graph", "query"] +categories = ["data-structures", "development-tools"] +publish = false + +[dependencies] +addressable.workspace = true + +[lints] +workspace = true diff --git a/crates/addressable_reference/src/catalog.rs b/crates/addressable_reference/src/catalog.rs new file mode 100644 index 0000000..bf1bf21 --- /dev/null +++ b/crates/addressable_reference/src/catalog.rs @@ -0,0 +1,249 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! A second small result space and evidence-bearing basilica correspondence. + +use std::vec::Vec; + +use addressable::{ + AbsoluteAddress, Correspondence, CorrespondenceTarget, Location, Locator, Resolution, Revision, + SpaceId, +}; + +use crate::{Basilica, BasilicaSpace, FeatureId, OccurrenceId}; + +/// Type marker for the catalog result space. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CatalogSpace {} + +/// The catalog's named result view. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CatalogView { + /// Ranked retrieval result occurrences. + Results, +} + +/// Durable semantic identity of one catalog result. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CatalogEntryId(u64); + +impl CatalogEntryId { + /// Creates a catalog result identity. + #[must_use] + pub const fn new(raw: u64) -> Self { + Self(raw) + } + + /// Returns the catalog-assigned value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Contextual occurrence identity of one ranked catalog result. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CatalogOccurrenceId(u64); + +impl CatalogOccurrenceId { + /// Creates a result occurrence identity. + #[must_use] + pub const fn new(raw: u64) -> Self { + Self(raw) + } + + /// Returns the catalog-assigned value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Resolved catalog result occurrence. +pub type CatalogLocation = Location; + +/// View-qualified catalog locator. +pub type CatalogLocator = Locator; + +/// Rich catalog resolution outcome. +pub type CatalogResolution = + Resolution>; + +/// Provenance for one basilica-to-catalog mapping. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CatalogEvidence { + source_space: SpaceId, + source_occurrence: OccurrenceId, + reason: &'static str, +} + +impl CatalogEvidence { + /// Returns the basilica instance observed by cataloging. + #[must_use] + pub const fn source_space(self) -> SpaceId { + self.source_space + } + + /// Returns the assembly occurrence that produced this result occurrence. + #[must_use] + pub const fn source_occurrence(self) -> OccurrenceId { + self.source_occurrence + } + + /// Returns stable ranking evidence. + #[must_use] + pub const fn reason(self) -> &'static str { + self.reason + } +} + +#[derive(Clone, Debug)] +struct CatalogEntry { + id: CatalogEntryId, + occurrence: CatalogOccurrenceId, + subject: FeatureId, + source_occurrence: OccurrenceId, + address: AbsoluteAddress, +} + +/// Deterministic second address space containing ranked basilica results. +#[derive(Clone, Debug)] +pub struct Catalog { + id: SpaceId, + revision: Revision, + entries: Vec, +} + +impl Catalog { + /// Constructs deterministic catalog results for the reference basilica. + #[must_use] + pub fn new(id: SpaceId) -> Self { + Self { + id, + revision: Revision::initial(id), + entries: vec![ + entry(1, 1, 3, 3, "/results/north_arch"), + entry(2, 2, 3, 4, "/results/south_arch"), + entry(3, 3, 4, 5, "/results/vault"), + ], + } + } + + /// Returns the runtime catalog identity. + #[must_use] + pub const fn id(&self) -> SpaceId { + self.id + } + + /// Returns the current catalog revision. + #[must_use] + pub const fn revision(&self) -> Revision { + self.revision + } + + /// Resolves one exact or relative result locator. + #[must_use] + pub fn resolve(&self, locator: &CatalogLocator) -> CatalogResolution { + if locator.space() != self.id || *locator.view() != CatalogView::Results { + return Resolution::UnsupportedLocator; + } + let Ok(address) = locator.to_absolute() else { + return Resolution::UnsupportedLocator; + }; + self.entries + .iter() + .find(|entry| entry.address == address) + .map_or(Resolution::Absent, |entry| { + Resolution::Resolved(self.location(entry)) + }) + } + + fn location(&self, entry: &CatalogEntry) -> CatalogLocation { + CatalogLocation::new( + CatalogView::Results, + self.revision, + entry.id, + entry.occurrence, + entry.address.clone(), + ) + } +} + +impl Basilica { + /// Maps one semantic basilica feature into zero or more catalog result occurrences. + #[must_use] + pub fn correspond_to_catalog( + &self, + referent: FeatureId, + catalog: &Catalog, + ) -> Correspondence { + Correspondence::new( + referent, + catalog + .entries + .iter() + .filter(|entry| entry.subject == referent) + .map(|entry| { + CorrespondenceTarget::new( + catalog.location(entry), + CatalogEvidence { + source_space: self.id(), + source_occurrence: entry.source_occurrence, + reason: "catalog/ranked-basilica-occurrence", + }, + ) + }), + ) + } +} + +fn entry( + id: u64, + occurrence: u64, + subject: u64, + source_occurrence: u64, + address: &str, +) -> CatalogEntry { + CatalogEntry { + id: CatalogEntryId::new(id), + occurrence: CatalogOccurrenceId::new(occurrence), + subject: FeatureId::new(subject), + source_occurrence: OccurrenceId::new(source_occurrence), + address: AbsoluteAddress::parse(address).expect("static catalog address must be valid"), + } +} + +#[cfg(test)] +mod tests { + use addressable::{AbsoluteAddress, Locator, Resolution, SpaceId}; + + use crate::{Basilica, BasilicaSpace, Catalog, CatalogSpace, CatalogView, FeatureId}; + + #[test] + fn correspondence_preserves_one_to_many_occurrences_and_evidence() { + let basilica = Basilica::new(SpaceId::::new(1)); + let catalog = Catalog::new(SpaceId::::new(2)); + let correspondence = basilica.correspond_to_catalog(FeatureId::new(3), &catalog); + assert!(correspondence.is_ambiguous()); + assert_eq!(correspondence.targets().len(), 2); + assert_ne!( + correspondence.targets()[0].target().occurrence(), + correspondence.targets()[1].target().occurrence() + ); + assert_eq!( + correspondence.targets()[0].provenance().source_space(), + basilica.id() + ); + } + + #[test] + fn catalog_is_an_independently_resolvable_space() { + let catalog = Catalog::new(SpaceId::::new(2)); + let locator = Locator::exact( + catalog.id(), + CatalogView::Results, + AbsoluteAddress::parse("/results/north_arch").expect("valid result address"), + ); + assert!(matches!(catalog.resolve(&locator), Resolution::Resolved(_))); + } +} diff --git a/crates/addressable_reference/src/lib.rs b/crates/addressable_reference/src/lib.rs new file mode 100644 index 0000000..4e60827 --- /dev/null +++ b/crates/addressable_reference/src/lib.rs @@ -0,0 +1,46 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Scanning reference object spaces for Addressable. +//! +//! [`Basilica`] is deliberately small, but it is a real host for the complete +//! lifecycle: structured resolution, multi-view queries, typed explanation, +//! live deltas, guarded transactions, and correspondence into [`Catalog`]. +//! It uses linear scans so the semantic contracts remain visible. +//! +//! ``` +//! use addressable::{CyclePolicy, Deduplication, Query, SpaceId, VisitIdentity}; +//! use addressable_reference::{ +//! Basilica, BasilicaAxis, BasilicaPredicate, BasilicaSpace, FeatureKind, +//! }; +//! +//! let space = Basilica::new(SpaceId::::new(1)); +//! let query = Query::many(space.root_locator()) +//! .traverse(BasilicaAxis::Descendants) +//! .filter(BasilicaPredicate::Kind(FeatureKind::Arch)) +//! .deduplicate(Deduplication::Occurrence) +//! .cycles(CyclePolicy::SkipVisited(VisitIdentity::Occurrence)); +//! let arches = space.query_many(&query)?; +//! assert_eq!(arches.items().len(), 2); +//! # Ok::<(), addressable::QueryError>(()) +//! ``` + +mod catalog; +mod model; +mod mutation; +mod space; +mod watch; + +pub use catalog::{ + Catalog, CatalogEntryId, CatalogEvidence, CatalogLocation, CatalogLocator, CatalogOccurrenceId, + CatalogResolution, CatalogSpace, CatalogView, +}; +pub use model::{ + BasilicaAxis, BasilicaLocation, BasilicaLocator, BasilicaPredicate, BasilicaQuery, + BasilicaResolution, BasilicaSpace, BasilicaView, BasilicaViewParseError, EdgeId, + EditCapability, FeatureId, FeatureKind, Load, LoadProvenance, LoadReason, OccurrenceId, + SlotHandle, +}; +pub use mutation::{LoadChange, SetLoad, TransactionConflict, TransactionReport, UndoLoad}; +pub use space::{Basilica, Measured, ReadError}; +pub use watch::{BasilicaWatch, WatchError}; diff --git a/crates/addressable_reference/src/model.rs b/crates/addressable_reference/src/model.rs new file mode 100644 index 0000000..9849cef --- /dev/null +++ b/crates/addressable_reference/src/model.rs @@ -0,0 +1,265 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Reference-domain identities and typed query vocabulary. + +use std::{fmt, num::ParseIntError, str::FromStr, string::String}; + +use addressable::{AbsoluteAddress, Location, Locator, Many, Query, Resolution, Revision}; + +/// Type marker for the basilica address space. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BasilicaSpace {} + +/// Durable semantic identity of one basilica feature. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FeatureId(u64); + +impl FeatureId { + /// Creates a durable feature id from a domain-assigned value. + #[must_use] + pub const fn new(raw: u64) -> Self { + Self(raw) + } + + /// Returns the domain-assigned value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +impl fmt::Display for FeatureId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl FromStr for FeatureId { + type Err = ParseIntError; + + fn from_str(text: &str) -> Result { + text.parse().map(Self) + } +} + +/// Identity of one contextual appearance in one basilica view. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OccurrenceId(u64); + +impl OccurrenceId { + /// Creates an occurrence id from a host-assigned value. + #[must_use] + pub const fn new(raw: u64) -> Self { + Self(raw) + } + + /// Returns the host-assigned value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Identity of one addressable relationship occurrence. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EdgeId(u64); + +impl EdgeId { + pub(crate) const fn new(raw: u64) -> Self { + Self(raw) + } + + /// Returns the host-assigned value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Runtime-local dense feature slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SlotHandle(u32); + +impl SlotHandle { + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } + + /// Returns the runtime-local slot value for diagnostics. + #[must_use] + pub const fn get(self) -> u32 { + self.0 + } +} + +/// Named view exposed by the basilica space. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BasilicaView { + /// Rooted assembly occurrences with canonical hierarchical addresses. + Assembly, + /// Relationship view over load dependencies, including a deliberate cycle. + Dependency, +} + +impl fmt::Display for BasilicaView { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Assembly => "assembly", + Self::Dependency => "dependency", + }) + } +} + +impl FromStr for BasilicaView { + type Err = BasilicaViewParseError; + + fn from_str(text: &str) -> Result { + match text { + "assembly" => Ok(Self::Assembly), + "dependency" => Ok(Self::Dependency), + _ => Err(BasilicaViewParseError), + } + } +} + +/// A dynamic or persisted view name was not part of the basilica schema. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BasilicaViewParseError; + +/// Semantic feature classification used by typed predicates. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FeatureKind { + /// Complete building. + Basilica, + /// Nave assembly. + Nave, + /// Shared semantic arch feature. + Arch, + /// Vault supported by the arch. + Vault, + /// Altar assembly. + Altar, +} + +/// Typed navigation axes understood by [`Basilica`]. +/// +/// [`Basilica`]: crate::Basilica +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum BasilicaAxis { + /// Direct assembly children. + Children, + /// Recursive outgoing relationships in the current view. + Descendants, + /// Direct outgoing dependency relationships. + Dependencies, + /// Direct incoming dependency relationships. + Dependents, + /// Cross explicitly to every occurrence of the same referent in a named view. + ToView(BasilicaView), +} + +/// Typed node predicates understood by [`Basilica`]. +/// +/// [`Basilica`]: crate::Basilica +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BasilicaPredicate { + /// Match every occurrence. + Any, + /// Match a semantic feature kind. + Kind(FeatureKind), + /// Match an effective load greater than or equal to a threshold. + LoadAtLeast(i64), + /// Match a case-sensitive substring in the feature name. + NameContains(String), +} + +/// Resolved occurrence type for the basilica space. +pub type BasilicaLocation = Location; + +/// View-qualified locator type for the basilica space. +pub type BasilicaLocator = Locator; + +/// Rich resolution outcome for a basilica occurrence. +pub type BasilicaResolution = + Resolution>; + +/// Typed basilica query, defaulting to many-result cardinality. +pub type BasilicaQuery = Query; + +/// Marker for the typed effective-load endpoint. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Load; + +/// Capability required to change a load endpoint. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum EditCapability { + /// Author an effective load opinion. + SetLoad, +} + +/// Domain-owned provenance for one load opinion. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LoadProvenance { + /// Explicitly authored load. + Authored { + /// Revision at which the opinion was authored. + revision: Revision, + }, + /// Schema-provided fallback. + Default { + /// Stable schema rule name. + rule: &'static str, + }, +} + +/// Domain-owned reason explaining the winning load opinion. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LoadReason { + /// The authored opinion has greater strength than the default. + AuthoredOverridesDefault, + /// No authored opinion exists, so the default is effective. + DefaultUsed, +} + +#[derive(Clone, Debug)] +pub(crate) struct Feature { + pub(crate) id: FeatureId, + pub(crate) name: String, + pub(crate) kind: FeatureKind, + pub(crate) default_load: i64, + pub(crate) authored_load: Option, + pub(crate) authored_revision: Revision, + pub(crate) editable: bool, +} + +impl Feature { + pub(crate) const fn effective_load(&self) -> i64 { + match self.authored_load { + Some(load) => load, + None => self.default_load, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Occurrence { + pub(crate) id: OccurrenceId, + pub(crate) referent: FeatureId, + pub(crate) view: BasilicaView, + pub(crate) address: AbsoluteAddress, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EdgeKind { + Assembly, + Dependency, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Edge { + pub(crate) id: EdgeId, + pub(crate) from: OccurrenceId, + pub(crate) to: OccurrenceId, + pub(crate) kind: EdgeKind, +} diff --git a/crates/addressable_reference/src/mutation.rs b/crates/addressable_reference/src/mutation.rs new file mode 100644 index 0000000..75848a4 --- /dev/null +++ b/crates/addressable_reference/src/mutation.rs @@ -0,0 +1,432 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Typed load operation and atomic guarded transaction execution. + +use std::vec::Vec; + +use addressable::{Endpoint, Guard, Revision, Transaction, TransactionMode}; + +use crate::{ + Basilica, BasilicaLocation, BasilicaSpace, EditCapability, FeatureId, Load, ReadError, +}; + +/// Typed operation that authors one effective load. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SetLoad { + endpoint: Endpoint, + value: i64, + guard: Guard, +} + +impl SetLoad { + /// Creates a guarded load operation. + #[must_use] + pub const fn new( + endpoint: Endpoint, + value: i64, + guard: Guard, + ) -> Self { + Self { + endpoint, + value, + guard, + } + } + + /// Returns the typed endpoint. + #[must_use] + pub const fn endpoint(&self) -> &Endpoint { + &self.endpoint + } + + /// Returns the proposed effective load. + #[must_use] + pub const fn value(&self) -> i64 { + self.value + } + + /// Returns all mutation preconditions. + #[must_use] + pub const fn guard(&self) -> &Guard { + &self.guard + } +} + +/// One effective value change reported by a transaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LoadChange { + referent: FeatureId, + previous: i64, + current: i64, +} + +impl LoadChange { + /// Returns the changed semantic referent. + #[must_use] + pub const fn referent(self) -> FeatureId { + self.referent + } + + /// Returns the previous effective load. + #[must_use] + pub const fn previous(self) -> i64 { + self.previous + } + + /// Returns the new effective load. + #[must_use] + pub const fn current(self) -> i64 { + self.current + } +} + +/// Undo information for one applied authored opinion. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UndoLoad { + referent: FeatureId, + previous_authored: Option, + expected_authored: i64, +} + +impl UndoLoad { + /// Returns the referent whose authored opinion can be restored. + #[must_use] + pub const fn referent(self) -> FeatureId { + self.referent + } + + /// Returns the authored state that existed before the transaction. + #[must_use] + pub const fn previous_authored(self) -> Option { + self.previous_authored + } + + /// Returns the authored value that an undo operation must still observe. + #[must_use] + pub const fn expected_authored(self) -> i64 { + self.expected_authored + } +} + +/// Successful dry-run or applied transaction report. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TransactionReport { + mode: TransactionMode, + revision_before: Revision, + revision_after: Revision, + changes: Vec, + undo: Vec, +} + +impl TransactionReport { + /// Returns whether state was previewed or applied. + #[must_use] + pub const fn mode(&self) -> TransactionMode { + self.mode + } + + /// Returns the revision validated by the transaction. + #[must_use] + pub const fn revision_before(&self) -> Revision { + self.revision_before + } + + /// Returns the resulting revision. A dry run retains the previous revision. + #[must_use] + pub const fn revision_after(&self) -> Revision { + self.revision_after + } + + /// Returns effective value changes in operation order. + #[must_use] + pub fn changes(&self) -> &[LoadChange] { + &self.changes + } + + /// Returns sufficient authored-state information for a separately guarded undo. + #[must_use] + pub fn undo(&self) -> &[UndoLoad] { + &self.undo + } +} + +/// Atomic transaction conflict. No operation is observable when this is returned. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TransactionConflict { + /// The bulk selection snapshot is no longer current. + SelectionRevision { + /// Transaction selection revision. + expected: Revision, + /// Current space revision. + actual: Revision, + }, + /// An endpoint could not be used at the current revision. + Endpoint { + /// Operation index. + operation: usize, + /// Read-side validation failure. + error: ReadError, + }, + /// Endpoint and guard name different referents. + ReferentMismatch { + /// Operation index. + operation: usize, + }, + /// The operation's guard was established at another revision. + GuardRevision { + /// Operation index. + operation: usize, + /// Guard revision. + expected: Revision, + /// Current revision. + actual: Revision, + }, + /// Effective value changed since the guard was established. + ValueMismatch { + /// Operation index. + operation: usize, + /// Guarded value. + expected: i64, + /// Current effective value. + actual: i64, + }, + /// The referent does not permit this edit capability. + CapabilityUnavailable { + /// Operation index. + operation: usize, + }, + /// Several operations target the same referent in one atomic batch. + DuplicateTarget { + /// Later conflicting operation index. + operation: usize, + }, + /// Guarded semantic identity no longer exists. + MissingReferent { + /// Operation index. + operation: usize, + }, +} + +#[derive(Clone, Copy, Debug)] +struct PreparedLoad { + referent: FeatureId, + previous_effective: i64, + previous_authored: Option, + current: i64, +} + +impl Basilica { + /// Atomically validates and previews or applies typed load operations. + pub fn transact( + &mut self, + transaction: Transaction, + ) -> Result { + if transaction.selection_revision() != self.revision { + return Err(TransactionConflict::SelectionRevision { + expected: transaction.selection_revision(), + actual: self.revision, + }); + } + + let mut prepared = Vec::new(); + for (operation, edit) in transaction.operations().iter().enumerate() { + self.validate_location(edit.endpoint.owner()) + .map_err(|error| TransactionConflict::Endpoint { operation, error })?; + if edit.endpoint.owner().referent() != edit.guard.expected_referent() { + return Err(TransactionConflict::ReferentMismatch { operation }); + } + if edit.guard.expected_revision() != self.revision { + return Err(TransactionConflict::GuardRevision { + operation, + expected: edit.guard.expected_revision(), + actual: self.revision, + }); + } + if *edit.guard.required_capability() != EditCapability::SetLoad { + return Err(TransactionConflict::CapabilityUnavailable { operation }); + } + let feature = self + .feature(*edit.guard.expected_referent()) + .ok_or(TransactionConflict::MissingReferent { operation })?; + if !feature.editable { + return Err(TransactionConflict::CapabilityUnavailable { operation }); + } + let current = feature.effective_load(); + if current != *edit.guard.expected_value() { + return Err(TransactionConflict::ValueMismatch { + operation, + expected: *edit.guard.expected_value(), + actual: current, + }); + } + if prepared + .iter() + .any(|prior: &PreparedLoad| prior.referent == feature.id) + { + return Err(TransactionConflict::DuplicateTarget { operation }); + } + prepared.push(PreparedLoad { + referent: feature.id, + previous_effective: current, + previous_authored: feature.authored_load, + current: edit.value, + }); + } + + let changes = prepared + .iter() + .filter(|change| change.previous_effective != change.current) + .map(|change| LoadChange { + referent: change.referent, + previous: change.previous_effective, + current: change.current, + }) + .collect::>(); + let undo = prepared + .iter() + .filter(|change| change.previous_authored != Some(change.current)) + .map(|change| UndoLoad { + referent: change.referent, + previous_authored: change.previous_authored, + expected_authored: change.current, + }) + .collect::>(); + + let revision_before = self.revision; + if transaction.mode() == TransactionMode::Apply && !undo.is_empty() { + let revision_after = self.revision.next(); + for change in &prepared { + let feature = self + .feature_mut(change.referent) + .expect("validated referent must remain present during atomic apply"); + feature.authored_load = Some(change.current); + feature.authored_revision = revision_after; + } + self.revision = revision_after; + } + + Ok(TransactionReport { + mode: transaction.mode(), + revision_before, + revision_after: self.revision, + changes, + undo, + }) + } +} + +#[cfg(test)] +mod tests { + use addressable::{ + AbsoluteAddress, Endpoint, Guard, Locator, Resolution, SpaceId, Transaction, + TransactionMode, + }; + + use crate::{ + Basilica, BasilicaSpace, BasilicaView, EditCapability, FeatureId, Load, SetLoad, + TransactionConflict, + }; + + fn endpoint(space: &Basilica, address: &str) -> Endpoint { + let locator = Locator::exact( + space.id(), + BasilicaView::Assembly, + AbsoluteAddress::parse(address).expect("valid test address"), + ); + let Resolution::Resolved(location) = space.resolve(&locator) else { + panic!("test endpoint should resolve"); + }; + Endpoint::new(location, Load) + } + + #[test] + fn dry_run_reports_without_mutating_then_apply_advances_once() { + let mut space = Basilica::new(SpaceId::::new(1)); + let endpoint = endpoint(&space, "/basilica/nave/north_arch"); + let edit = SetLoad::new( + endpoint, + 80, + Guard::new( + FeatureId::new(3), + space.revision(), + 120, + EditCapability::SetLoad, + ), + ); + let preview = space + .transact(Transaction::dry_run(space.revision(), [edit.clone()])) + .expect("dry run should validate"); + assert_eq!(preview.mode(), TransactionMode::DryRun); + assert_eq!(space.revision(), addressable::Revision::initial(space.id())); + assert_eq!( + space.feature(FeatureId::new(3)).unwrap().effective_load(), + 120 + ); + + let applied = space + .transact(Transaction::apply(space.revision(), [edit])) + .expect("apply should validate"); + assert_eq!(applied.mode(), TransactionMode::Apply); + assert_eq!(applied.changes()[0].current(), 80); + assert_eq!(applied.undo()[0].previous_authored(), Some(120)); + assert_eq!(space.revision(), addressable::Revision::new(space.id(), 1)); + } + + #[test] + fn failed_batch_has_no_partial_observable_effect() { + let mut space = Basilica::new(SpaceId::::new(1)); + let arch = endpoint(&space, "/basilica/nave/north_arch"); + let vault = endpoint(&space, "/basilica/nave/vault"); + let edits = [ + SetLoad::new( + arch, + 80, + Guard::new( + FeatureId::new(3), + space.revision(), + 120, + EditCapability::SetLoad, + ), + ), + SetLoad::new( + vault, + 160, + Guard::new( + FeatureId::new(4), + space.revision(), + 999, + EditCapability::SetLoad, + ), + ), + ]; + + assert!(matches!( + space.transact(Transaction::apply(space.revision(), edits)), + Err(TransactionConflict::ValueMismatch { operation: 1, .. }) + )); + assert_eq!(space.revision(), addressable::Revision::initial(space.id())); + assert_eq!( + space.feature(FeatureId::new(3)).unwrap().effective_load(), + 120 + ); + } + + #[test] + fn transaction_revision_cannot_cross_space_instances() { + let first = Basilica::new(SpaceId::::new(1)); + let mut second = Basilica::new(SpaceId::::new(2)); + let original = second.revision(); + + let result = second.transact(Transaction::apply( + first.revision(), + core::iter::empty::(), + )); + + assert!(matches!( + result, + Err(TransactionConflict::SelectionRevision { .. }) + )); + assert_eq!(second.revision(), original); + } +} diff --git a/crates/addressable_reference/src/space.rs b/crates/addressable_reference/src/space.rs new file mode 100644 index 0000000..748051f --- /dev/null +++ b/crates/addressable_reference/src/space.rs @@ -0,0 +1,795 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Basilica construction, resolution, query execution, and typed reads. + +use std::{collections::VecDeque, vec::Vec}; + +use addressable::{ + AbsoluteAddress, BudgetDimension, BudgetExceeded, Cardinality, CardinalityKind, CyclePolicy, + Deduplication, Endpoint, Explained, Locator, Many, One, Opinion, Optional, Pinned, QueryError, + QueryResults, QuerySemantics, QueryStats, QueryStep, Resolution, ResolvedHandle, + ResultOrdering, Revision, SpaceId, TraversalBudget, VisitIdentity, +}; + +use crate::model::{ + BasilicaAxis, BasilicaLocation, BasilicaLocator, BasilicaPredicate, BasilicaQuery, + BasilicaResolution, BasilicaSpace, BasilicaView, Edge, EdgeId, EdgeKind, Feature, FeatureId, + FeatureKind, Load, LoadProvenance, LoadReason, Occurrence, OccurrenceId, SlotHandle, +}; + +/// Measured single-value or optional query output. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Measured { + value: T, + stats: QueryStats, +} + +impl Measured { + /// Pairs a cardinality-shaped value with measured query work. + #[must_use] + pub const fn new(value: T, stats: QueryStats) -> Self { + Self { value, stats } + } + + /// Returns the cardinality-shaped value. + #[must_use] + pub const fn value(&self) -> &T { + &self.value + } + + /// Returns measured query work. + #[must_use] + pub const fn stats(&self) -> QueryStats { + self.stats + } + + /// Decomposes the measured value. + #[must_use] + pub fn into_parts(self) -> (T, QueryStats) { + (self.value, self.stats) + } +} + +/// The complete scanning reference basilica space. +#[derive(Clone, Debug)] +pub struct Basilica { + pub(crate) id: SpaceId, + pub(crate) revision: Revision, + pub(crate) next_live_query: u64, + pub(crate) features: Vec, + pub(crate) occurrences: Vec, + pub(crate) edges: Vec, +} + +impl Basilica { + /// Constructs the deterministic reference basilica. + /// + /// The arch referent appears at both `north_arch` and `south_arch` in the + /// assembly view. The dependency view contains an arch/vault cycle. + #[must_use] + pub fn new(id: SpaceId) -> Self { + let revision = Revision::initial(id); + let features = vec![ + feature( + 1, + "Basilica", + FeatureKind::Basilica, + 0, + None, + revision, + false, + ), + feature(2, "Nave", FeatureKind::Nave, 20, None, revision, false), + feature(3, "Arch", FeatureKind::Arch, 100, Some(120), revision, true), + feature( + 4, + "Vault", + FeatureKind::Vault, + 180, + Some(200), + revision, + true, + ), + feature(5, "Altar", FeatureKind::Altar, 40, None, revision, false), + ]; + let occurrences = vec![ + occurrence(1, 1, BasilicaView::Assembly, "/basilica"), + occurrence(2, 2, BasilicaView::Assembly, "/basilica/nave"), + occurrence(3, 3, BasilicaView::Assembly, "/basilica/nave/north_arch"), + occurrence(4, 3, BasilicaView::Assembly, "/basilica/nave/south_arch"), + occurrence(5, 4, BasilicaView::Assembly, "/basilica/nave/vault"), + occurrence(6, 5, BasilicaView::Assembly, "/basilica/altar"), + occurrence(10, 1, BasilicaView::Dependency, "/dependencies/basilica"), + occurrence(11, 3, BasilicaView::Dependency, "/dependencies/arch"), + occurrence(12, 4, BasilicaView::Dependency, "/dependencies/vault"), + occurrence(13, 2, BasilicaView::Dependency, "/dependencies/nave"), + ]; + let edges = vec![ + edge(1, 1, 2, EdgeKind::Assembly), + edge(2, 1, 6, EdgeKind::Assembly), + edge(3, 2, 3, EdgeKind::Assembly), + edge(4, 2, 4, EdgeKind::Assembly), + edge(5, 2, 5, EdgeKind::Assembly), + edge(10, 10, 13, EdgeKind::Dependency), + edge(11, 13, 11, EdgeKind::Dependency), + edge(12, 11, 12, EdgeKind::Dependency), + edge(13, 12, 11, EdgeKind::Dependency), + ]; + Self { + id, + revision, + next_live_query: 0, + features, + occurrences, + edges, + } + } + + /// Returns the runtime identity of this address-space instance. + #[must_use] + pub const fn id(&self) -> SpaceId { + self.id + } + + /// Returns the current local revision. + #[must_use] + pub const fn revision(&self) -> Revision { + self.revision + } + + /// Iterates the identities of addressable relationship occurrences. + /// + /// Edge identity remains distinct from both endpoint occurrence ids. + pub fn edge_ids(&self) -> impl ExactSizeIterator + '_ { + self.edges.iter().map(|edge| edge.id) + } + + /// Returns an exact locator for the assembly root. + #[must_use] + pub fn root_locator(&self) -> BasilicaLocator { + Locator::exact( + self.id, + BasilicaView::Assembly, + AbsoluteAddress::parse("/basilica").expect("static root address must be valid"), + ) + } + + /// Resolves an exact or relative locator with rich outcome semantics. + #[must_use] + pub fn resolve(&self, locator: &BasilicaLocator) -> BasilicaResolution { + if locator.space() != self.id { + return Resolution::UnsupportedLocator; + } + let Ok(address) = locator.to_absolute() else { + return Resolution::UnsupportedLocator; + }; + self.occurrences + .iter() + .find(|occurrence| occurrence.view == *locator.view() && occurrence.address == address) + .map_or(Resolution::Absent, |occurrence| { + Resolution::Resolved(self.location(occurrence)) + }) + } + + /// Resolves a pinned locator without silently accepting rebinding. + #[must_use] + pub fn resolve_pinned( + &self, + pinned: &Pinned, + ) -> BasilicaResolution { + let locator = pinned.locator(); + if locator.space() != self.id { + return Resolution::UnsupportedLocator; + } + let Ok(address) = locator.to_absolute() else { + return Resolution::UnsupportedLocator; + }; + if let Some(occurrence) = self + .occurrences + .iter() + .find(|occurrence| occurrence.view == *locator.view() && occurrence.address == address) + { + let location = self.location(occurrence); + if occurrence.referent != *pinned.expected_referent() { + return Resolution::Rebound { + expected: *pinned.expected_referent(), + actual: occurrence.referent, + resolved: location, + }; + } + if pinned.expected_revision() != self.revision { + return Resolution::StaleRevision { + expected: pinned.expected_revision(), + actual: self.revision, + }; + } + return Resolution::Resolved(location); + } + + let moved = self + .occurrences + .iter() + .filter(|occurrence| { + occurrence.view == *locator.view() + && occurrence.referent == *pinned.expected_referent() + }) + .map(|occurrence| self.location(occurrence)) + .collect::>(); + match moved.as_slice() { + [] => Resolution::Absent, + [location] => Resolution::Moved { + from: address, + to: location.address().clone(), + resolved: location.clone(), + }, + _ => Resolution::Ambiguous(moved.into_boxed_slice()), + } + } + + /// Executes a many-result query. + pub fn query_many( + &self, + query: &BasilicaQuery, + ) -> Result, QueryError> { + self.execute(query) + } + + /// Executes a query that requires exactly one result. + pub fn query_one( + &self, + query: &BasilicaQuery, + ) -> Result, QueryError> { + let results = self.execute(query)?; + let (items, stats) = results.into_parts(); + if items.len() != 1 { + return Err(QueryError::Cardinality { + expected: CardinalityKind::One, + actual: items.len(), + }); + } + let item = items + .into_vec() + .pop() + .expect("cardinality was checked as exactly one"); + Ok(Measured::new(item, stats)) + } + + /// Executes a query that allows zero or one result. + pub fn query_optional( + &self, + query: &BasilicaQuery, + ) -> Result>, QueryError> { + let results = self.execute(query)?; + let (items, stats) = results.into_parts(); + if items.len() > 1 { + return Err(QueryError::Cardinality { + expected: CardinalityKind::Optional, + actual: items.len(), + }); + } + Ok(Measured::new(items.into_vec().pop(), stats)) + } + + /// Resolves a revision-scoped runtime feature slot. + pub fn resolved_handle( + &self, + location: &BasilicaLocation, + ) -> Result, ReadError> { + self.validate_location(location)?; + let index = self + .features + .iter() + .position(|feature| feature.id == *location.referent()) + .ok_or(ReadError::MissingReferent)?; + let slot = u32::try_from(index).map_err(|_| ReadError::MissingReferent)?; + Ok(ResolvedHandle::new(self.revision, SlotHandle::new(slot))) + } + + /// Reads and explains the effective typed load endpoint. + pub fn read_load( + &self, + endpoint: &Endpoint, + ) -> Result, ReadError> { + self.validate_location(endpoint.owner())?; + let feature = self + .feature(*endpoint.owner().referent()) + .ok_or(ReadError::MissingReferent)?; + let default = Opinion::new( + feature.default_load, + LoadProvenance::Default { + rule: "basilica/load/default", + }, + ); + match feature.authored_load { + Some(authored) => Explained::new( + feature.id, + [ + Opinion::new( + authored, + LoadProvenance::Authored { + revision: feature.authored_revision, + }, + ), + default, + ], + 0, + LoadReason::AuthoredOverridesDefault, + ) + .map_err(|_| ReadError::InvalidExplanation), + None => Explained::new(feature.id, [default], 0, LoadReason::DefaultUsed) + .map_err(|_| ReadError::InvalidExplanation), + } + } + + pub(crate) fn feature(&self, id: FeatureId) -> Option<&Feature> { + self.features.iter().find(|feature| feature.id == id) + } + + pub(crate) fn feature_mut(&mut self, id: FeatureId) -> Option<&mut Feature> { + self.features.iter_mut().find(|feature| feature.id == id) + } + + pub(crate) fn occurrence(&self, id: OccurrenceId) -> Option<&Occurrence> { + self.occurrences + .iter() + .find(|occurrence| occurrence.id == id) + } + + pub(crate) fn location(&self, occurrence: &Occurrence) -> BasilicaLocation { + BasilicaLocation::new( + occurrence.view, + self.revision, + occurrence.referent, + occurrence.id, + occurrence.address.clone(), + ) + } + + pub(crate) fn validate_location(&self, location: &BasilicaLocation) -> Result<(), ReadError> { + if location.space() != self.id { + return Err(ReadError::WrongSpace); + } + if location.revision() != self.revision { + return Err(ReadError::StaleRevision { + expected: location.revision(), + actual: self.revision, + }); + } + let occurrence = self + .occurrence(*location.occurrence()) + .ok_or(ReadError::MissingOccurrence)?; + if occurrence.referent != *location.referent() { + return Err(ReadError::Rebound); + } + Ok(()) + } + + fn execute( + &self, + query: &BasilicaQuery, + ) -> Result, QueryError> { + let Resolution::Resolved(start) = self.resolve(query.start()) else { + return Err(QueryError::StartDidNotResolve); + }; + let semantics = query.semantics(); + let mut stats = QueryStats::default(); + charge(&mut stats, semantics.budget, 1, 1, 0)?; + let mut frontier = vec![start]; + + for step in query.steps() { + frontier = match step { + QueryStep::Traverse(axis) => { + self.traverse(&frontier, *axis, semantics, &mut stats)? + } + QueryStep::Filter(predicate) => { + let inspected = u32::try_from(frontier.len()).unwrap_or(u32::MAX); + let filtered = frontier + .into_iter() + .filter(|location| self.matches(location, predicate)) + .collect(); + charge_work(&mut stats, semantics.budget, inspected)?; + filtered + } + }; + } + + deduplicate(&mut frontier, semantics.deduplication); + if semantics.ordering == ResultOrdering::Stable { + frontier.sort_by(|left, right| left.address().cmp(right.address())); + } + let result_count = u32::try_from(frontier.len()).unwrap_or(u32::MAX); + if result_count > semantics.budget.max_results { + return Err(QueryError::BudgetExceeded(BudgetExceeded::new( + BudgetDimension::Results, + semantics.budget.max_results, + result_count, + ))); + } + Ok(QueryResults::new(frontier, stats)) + } + + fn traverse( + &self, + frontier: &[BasilicaLocation], + axis: BasilicaAxis, + semantics: QuerySemantics, + stats: &mut QueryStats, + ) -> Result, QueryError> { + let mut output = Vec::new(); + for location in frontier { + match axis { + BasilicaAxis::Children => { + if *location.view() != BasilicaView::Assembly { + return Err(QueryError::UnsupportedStep); + } + self.push_direct( + location, + EdgeKind::Assembly, + false, + semantics.budget, + stats, + &mut output, + )?; + } + BasilicaAxis::Dependencies => { + if *location.view() != BasilicaView::Dependency { + return Err(QueryError::UnsupportedStep); + } + self.push_direct( + location, + EdgeKind::Dependency, + false, + semantics.budget, + stats, + &mut output, + )?; + } + BasilicaAxis::Dependents => { + if *location.view() != BasilicaView::Dependency { + return Err(QueryError::UnsupportedStep); + } + self.push_direct( + location, + EdgeKind::Dependency, + true, + semantics.budget, + stats, + &mut output, + )?; + } + BasilicaAxis::ToView(view) => { + for occurrence in self.occurrences.iter().filter(|occurrence| { + occurrence.view == view && occurrence.referent == *location.referent() + }) { + charge(stats, semantics.budget, 1, 1, 1)?; + output.push(self.location(occurrence)); + } + } + BasilicaAxis::Descendants => { + self.push_descendants(location, semantics, stats, &mut output)?; + } + } + } + Ok(output) + } + + fn push_direct( + &self, + location: &BasilicaLocation, + kind: EdgeKind, + reverse: bool, + budget: TraversalBudget, + stats: &mut QueryStats, + output: &mut Vec, + ) -> Result<(), QueryError> { + for edge in self.edges.iter().filter(|edge| { + edge.kind == kind + && if reverse { + edge.to == *location.occurrence() + } else { + edge.from == *location.occurrence() + } + }) { + let target = if reverse { edge.from } else { edge.to }; + let occurrence = self.occurrence(target).ok_or(QueryError::UnsupportedStep)?; + charge(stats, budget, 1, 1, 1)?; + output.push(self.location(occurrence)); + } + Ok(()) + } + + fn push_descendants( + &self, + start: &BasilicaLocation, + semantics: QuerySemantics, + stats: &mut QueryStats, + output: &mut Vec, + ) -> Result<(), QueryError> { + let kind = match start.view() { + BasilicaView::Assembly => EdgeKind::Assembly, + BasilicaView::Dependency => EdgeKind::Dependency, + }; + let mut queue = VecDeque::from([(*start.occurrence(), 0_u32)]); + let mut visited_occurrences = vec![*start.occurrence()]; + let mut visited_referents = vec![*start.referent()]; + + while let Some((current, depth)) = queue.pop_front() { + for edge in self + .edges + .iter() + .filter(|edge| edge.kind == kind && edge.from == current) + { + let occurrence = self + .occurrence(edge.to) + .ok_or(QueryError::UnsupportedStep)?; + let next_depth = depth.saturating_add(1); + if next_depth > semantics.budget.max_depth { + return Err(QueryError::BudgetExceeded(BudgetExceeded::new( + BudgetDimension::Depth, + semantics.budget.max_depth, + next_depth, + ))); + } + let revisited = match semantics.cycle_policy { + CyclePolicy::Error => visited_occurrences.contains(&occurrence.id), + CyclePolicy::SkipVisited(VisitIdentity::Occurrence) => { + visited_occurrences.contains(&occurrence.id) + } + CyclePolicy::SkipVisited(VisitIdentity::Referent) => { + visited_referents.contains(&occurrence.referent) + } + }; + if revisited { + if semantics.cycle_policy == CyclePolicy::Error { + return Err(QueryError::Cycle); + } + continue; + } + visited_occurrences.push(occurrence.id); + visited_referents.push(occurrence.referent); + charge(stats, semantics.budget, 1, 1, next_depth)?; + output.push(self.location(occurrence)); + queue.push_back((occurrence.id, next_depth)); + } + } + Ok(()) + } + + fn matches(&self, location: &BasilicaLocation, predicate: &BasilicaPredicate) -> bool { + let Some(feature) = self.feature(*location.referent()) else { + return false; + }; + match predicate { + BasilicaPredicate::Any => true, + BasilicaPredicate::Kind(kind) => feature.kind == *kind, + BasilicaPredicate::LoadAtLeast(threshold) => feature.effective_load() >= *threshold, + BasilicaPredicate::NameContains(fragment) => feature.name.contains(fragment), + } + } +} + +/// Failure to read through a resolved endpoint or handle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReadError { + /// The location belongs to another runtime space instance. + WrongSpace, + /// The location was resolved at an earlier revision. + StaleRevision { + /// Location revision. + expected: Revision, + /// Current space revision. + actual: Revision, + }, + /// The contextual occurrence no longer exists. + MissingOccurrence, + /// The occurrence now denotes another referent. + Rebound, + /// The semantic referent no longer exists. + MissingReferent, + /// Host data violated the explanation constructor invariant. + InvalidExplanation, +} + +fn feature( + id: u64, + name: &str, + kind: FeatureKind, + default_load: i64, + authored_load: Option, + authored_revision: Revision, + editable: bool, +) -> Feature { + Feature { + id: FeatureId::new(id), + name: name.into(), + kind, + default_load, + authored_load, + authored_revision, + editable, + } +} + +fn occurrence(id: u64, referent: u64, view: BasilicaView, address: &str) -> Occurrence { + Occurrence { + id: OccurrenceId::new(id), + referent: FeatureId::new(referent), + view, + address: AbsoluteAddress::parse(address).expect("static address must be valid"), + } +} + +const fn edge(id: u64, from: u64, to: u64, kind: EdgeKind) -> Edge { + Edge { + id: EdgeId::new(id), + from: OccurrenceId::new(from), + to: OccurrenceId::new(to), + kind, + } +} + +fn charge( + stats: &mut QueryStats, + budget: TraversalBudget, + nodes: u32, + work: u32, + depth: u32, +) -> Result<(), QueryError> { + stats.visited_nodes = stats.visited_nodes.saturating_add(nodes); + stats.work_units = stats.work_units.saturating_add(work); + stats.max_depth_reached = stats.max_depth_reached.max(depth); + if depth > budget.max_depth { + return Err(QueryError::BudgetExceeded(BudgetExceeded::new( + BudgetDimension::Depth, + budget.max_depth, + depth, + ))); + } + if stats.visited_nodes > budget.max_nodes { + return Err(QueryError::BudgetExceeded(BudgetExceeded::new( + BudgetDimension::Nodes, + budget.max_nodes, + stats.visited_nodes, + ))); + } + if stats.work_units > budget.max_work { + return Err(QueryError::BudgetExceeded(BudgetExceeded::new( + BudgetDimension::Work, + budget.max_work, + stats.work_units, + ))); + } + Ok(()) +} + +fn charge_work( + stats: &mut QueryStats, + budget: TraversalBudget, + work: u32, +) -> Result<(), QueryError> { + charge(stats, budget, 0, work, stats.max_depth_reached) +} + +fn deduplicate(frontier: &mut Vec, identity: Deduplication) { + match identity { + Deduplication::None => {} + Deduplication::Occurrence => { + let mut seen = Vec::new(); + frontier.retain(|location| { + if seen.contains(location.occurrence()) { + false + } else { + seen.push(*location.occurrence()); + true + } + }); + } + Deduplication::Referent => { + let mut seen = Vec::new(); + frontier.retain(|location| { + if seen.contains(location.referent()) { + false + } else { + seen.push(*location.referent()); + true + } + }); + } + } +} + +#[cfg(test)] +mod tests { + use addressable::{ + AbsoluteAddress, CyclePolicy, Deduplication, Locator, Pinned, Query, ResultOrdering, + SpaceId, TraversalBudget, VisitIdentity, + }; + + use super::Basilica; + use crate::{BasilicaAxis, BasilicaPredicate, BasilicaSpace, BasilicaView, FeatureKind}; + + #[test] + fn exact_relative_and_pinned_resolution_preserve_identity() { + let mut space = Basilica::new(SpaceId::new(7)); + let north_address = + AbsoluteAddress::parse("/basilica/nave/north_arch").expect("valid north address"); + let exact = Locator::exact(space.id(), BasilicaView::Assembly, north_address.clone()); + let addressable::Resolution::Resolved(north) = space.resolve(&exact) else { + panic!("north arch should resolve"); + }; + let relative = Locator::relative( + space.id(), + BasilicaView::Assembly, + AbsoluteAddress::parse("/basilica/nave").expect("valid base"), + addressable::RelativeAddress::parse("south_arch").expect("valid relative path"), + ); + let addressable::Resolution::Resolved(south) = space.resolve(&relative) else { + panic!("south arch should resolve"); + }; + assert_eq!(north.referent(), south.referent()); + assert_ne!(north.occurrence(), south.occurrence()); + + let pinned = Pinned::new(exact, *north.referent(), space.revision()); + space + .occurrences + .iter_mut() + .find(|occurrence| occurrence.address == north_address) + .expect("test occurrence exists") + .referent = crate::FeatureId::new(5); + assert!(matches!( + space.resolve_pinned(&pinned), + addressable::Resolution::Rebound { .. } + )); + } + + #[test] + fn queries_make_occurrence_and_referent_deduplication_explicit() { + let space = Basilica::new(SpaceId::::new(1)); + let occurrence_query = Query::many(space.root_locator()) + .traverse(BasilicaAxis::Descendants) + .filter(BasilicaPredicate::Kind(FeatureKind::Arch)) + .deduplicate(Deduplication::Occurrence) + .order(ResultOrdering::Stable) + .cycles(CyclePolicy::SkipVisited(VisitIdentity::Occurrence)); + let referent_query = occurrence_query + .clone() + .deduplicate(Deduplication::Referent); + + assert_eq!( + space + .query_many(&occurrence_query) + .expect("assembly query succeeds") + .items() + .len(), + 2 + ); + assert_eq!( + space + .query_many(&referent_query) + .expect("referent query succeeds") + .items() + .len(), + 1 + ); + } + + #[test] + fn explicit_view_crossing_terminates_a_dependency_cycle() { + let space = Basilica::new(SpaceId::::new(1)); + let query = Query::many(space.root_locator()) + .traverse(BasilicaAxis::ToView(BasilicaView::Dependency)) + .traverse(BasilicaAxis::Descendants) + .deduplicate(Deduplication::Occurrence) + .cycles(CyclePolicy::SkipVisited(VisitIdentity::Occurrence)) + .budget(TraversalBudget::new(8, 32, 16, 64)); + let results = space.query_many(&query).expect("cycle policy terminates"); + assert_eq!(results.items().len(), 3); + + let error_query = query.clone().cycles(CyclePolicy::Error); + assert_eq!( + space.query_many(&error_query), + Err(addressable::QueryError::Cycle) + ); + } +} diff --git a/crates/addressable_reference/src/watch.rs b/crates/addressable_reference/src/watch.rs new file mode 100644 index 0000000..fbca95c --- /dev/null +++ b/crates/addressable_reference/src/watch.rs @@ -0,0 +1,156 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Scanning live-query watch with coherent delta semantics. + +use addressable::{ + Deduplication, DeltaError, LiveQueryId, Many, QueryDelta, QueryError, QuerySnapshot, + ResultEntry, ResultIdentity, +}; + +use crate::{Basilica, BasilicaLocation, BasilicaQuery, BasilicaSpace, OccurrenceId}; + +/// A live many-result query tracked by occurrence identity. +#[derive(Clone, Debug)] +pub struct BasilicaWatch { + query: BasilicaQuery, + snapshot: QuerySnapshot, +} + +impl BasilicaWatch { + /// Returns the typed query being watched. + #[must_use] + pub const fn query(&self) -> &BasilicaQuery { + &self.query + } + + /// Returns the latest complete snapshot. + #[must_use] + pub const fn snapshot(&self) -> &QuerySnapshot { + &self.snapshot + } + + /// Recomputes against the current revision and emits a coherent delta. + /// + /// The scanning implementation is replaceable; the delta contract is not. + pub fn poll( + &mut self, + space: &Basilica, + ) -> Result, WatchError> { + let next = snapshot(space, self.snapshot.live_query(), &self.query)?; + let delta = QueryDelta::between(&self.snapshot, &next).map_err(WatchError::Delta)?; + self.snapshot = next; + Ok(delta) + } +} + +impl Basilica { + /// Starts a scanning live query. + /// + /// This first watcher deliberately accepts occurrence deduplication only, + /// making its stable entry identity explicit. + pub fn watch(&mut self, query: BasilicaQuery) -> Result { + if query.semantics().deduplication != Deduplication::Occurrence { + return Err(WatchError::IdentityNotOccurrence); + } + let live_query = LiveQueryId::new(self.next_live_query); + self.next_live_query = self + .next_live_query + .checked_add(1) + .ok_or(WatchError::LiveQueryIdsExhausted)?; + let snapshot = snapshot(self, live_query, &query)?; + Ok(BasilicaWatch { query, snapshot }) + } +} + +fn snapshot( + space: &Basilica, + live_query: LiveQueryId, + query: &BasilicaQuery, +) -> Result, WatchError> { + let results = space.query_many(query).map_err(WatchError::Query)?; + Ok(QuerySnapshot::new( + live_query, + space.revision(), + ResultIdentity::Occurrence, + results + .items() + .iter() + .cloned() + .map(|location| ResultEntry::new(*location.occurrence(), location)), + )) +} + +/// Failure to start or advance a basilica watch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WatchError { + /// This watcher requires occurrence-deduplicated results. + IdentityNotOccurrence, + /// The host cannot allocate another unique live-query identity. + LiveQueryIdsExhausted, + /// Full query recomputation failed. + Query(QueryError), + /// Snapshot differencing failed. + Delta(DeltaError), +} + +#[cfg(test)] +mod tests { + use addressable::{ + CyclePolicy, Deduplication, Endpoint, Guard, Query, SpaceId, Transaction, VisitIdentity, + }; + + use crate::{ + Basilica, BasilicaAxis, BasilicaPredicate, BasilicaSpace, EditCapability, FeatureId, Load, + SetLoad, + }; + + #[test] + fn emitted_delta_replays_to_full_recomputation() { + let mut space = Basilica::new(SpaceId::::new(1)); + let query = Query::many(space.root_locator()) + .traverse(BasilicaAxis::Descendants) + .filter(BasilicaPredicate::LoadAtLeast(100)) + .deduplicate(Deduplication::Occurrence) + .cycles(CyclePolicy::SkipVisited(VisitIdentity::Occurrence)); + let mut watch = space.watch(query).expect("watch starts"); + let mut replayed = watch.snapshot().clone(); + let arch = replayed + .entries() + .iter() + .find(|entry| *entry.value().referent() == FeatureId::new(3)) + .expect("arch result exists") + .value() + .clone(); + let edit = SetLoad::new( + Endpoint::new(arch, Load), + 80, + Guard::new( + FeatureId::new(3), + space.revision(), + 120, + EditCapability::SetLoad, + ), + ); + space + .transact(Transaction::apply(space.revision(), [edit])) + .expect("guarded edit applies"); + + let delta = watch.poll(&space).expect("watch advances"); + replayed.apply(&delta).expect("delta replays"); + assert_eq!(&replayed, watch.snapshot()); + } + + #[test] + fn exhausted_live_query_ids_do_not_wrap() { + let mut space = Basilica::new(SpaceId::::new(1)); + space.next_live_query = u64::MAX; + let query = Query::many(space.root_locator()); + + assert!(matches!( + space.watch(query), + Err(super::WatchError::LiveQueryIdsExhausted) + )); + assert_eq!(space.next_live_query, u64::MAX); + } +} diff --git a/crates/addressable_tooling/Cargo.toml b/crates/addressable_tooling/Cargo.toml new file mode 100644 index 0000000..b5ec180 --- /dev/null +++ b/crates/addressable_tooling/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "addressable_tooling" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Schema-backed dynamic tooling adapter for Addressable" +keywords = ["address", "inspection", "schema"] +categories = ["development-tools"] +publish = false + +[dependencies] +addressable.workspace = true +addressable_reference.workspace = true + +[lints] +workspace = true diff --git a/crates/addressable_tooling/src/lib.rs b/crates/addressable_tooling/src/lib.rs new file mode 100644 index 0000000..d2716bb --- /dev/null +++ b/crates/addressable_tooling/src/lib.rs @@ -0,0 +1,539 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Schema-backed dynamic adaptation for Addressable. +//! +//! Erasure is deliberately confined to this crate. [`ReferenceTool`] validates +//! schema names and dynamic value kinds, then reconstructs the same typed +//! endpoint, guard, and transaction used by ordinary Rust callers. + +use std::{string::String, vec::Vec}; + +use addressable::{ + AbsoluteAddress, AddressError, Endpoint, Guard, Locator, Opinion, Resolution, Revision, + Transaction, TransactionMode, +}; +use addressable_reference::{ + Basilica, BasilicaLocation, BasilicaView, EditCapability, FeatureId, Load, LoadProvenance, + ReadError, SetLoad, TransactionConflict, +}; + +/// Dynamic value kind declared by a tooling schema. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DynamicKind { + /// Signed 64-bit integer. + Integer, + /// Owned UTF-8 text. + Text, +} + +/// Value crossing the schema-backed tooling boundary. +/// +/// This enum is not used by `addressable` or by typed reference storage. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DynamicValue { + /// Signed 64-bit integer. + Integer(i64), + /// Owned UTF-8 text. + Text(String), +} + +impl DynamicValue { + /// Returns the schema kind of this value. + #[must_use] + pub const fn kind(&self) -> DynamicKind { + match self { + Self::Integer(_) => DynamicKind::Integer, + Self::Text(_) => DynamicKind::Text, + } + } +} + +/// Operation capability declared for one dynamic facet. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ToolCapability { + /// Read the effective value. + Read, + /// Explain candidates and provenance. + Explain, + /// Apply a guarded set operation. + Set, +} + +/// Schema for one named address-space view. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ViewSchema { + /// Stable dynamic name. + pub name: &'static str, +} + +/// Schema for one addressable facet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FacetSchema { + /// Stable dynamic name. + pub name: &'static str, + /// Accepted value kind. + pub value_kind: DynamicKind, + /// Supported operations. + pub capabilities: &'static [ToolCapability], +} + +/// Declared dynamic schema for one typed object-space adapter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ObjectSpaceSchema { + /// Stable schema identity. + pub name: &'static str, + /// Named views accepted by dynamic locators. + pub views: &'static [ViewSchema], + /// Addressable facets exposed by the adapter. + pub facets: &'static [FacetSchema], +} + +const VIEWS: &[ViewSchema] = &[ + ViewSchema { name: "assembly" }, + ViewSchema { name: "dependency" }, +]; +const LOAD_CAPABILITIES: &[ToolCapability] = &[ + ToolCapability::Read, + ToolCapability::Explain, + ToolCapability::Set, +]; +const FACETS: &[FacetSchema] = &[FacetSchema { + name: "load", + value_kind: DynamicKind::Integer, + capabilities: LOAD_CAPABILITIES, +}]; +const BASILICA_SCHEMA: ObjectSpaceSchema = ObjectSpaceSchema { + name: "addressable.reference.basilica/v1", + views: VIEWS, + facets: FACETS, +}; + +/// Erased but schema-qualified locator. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DynamicLocator { + /// Runtime space id. + pub space: u64, + /// Stable view name from [`ObjectSpaceSchema::views`]. + pub view: String, + /// Textual exact address to parse into the typed representation. + pub address: String, +} + +/// Erased typed-facet endpoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DynamicEndpoint { + /// Located owner recipe. + pub owner: DynamicLocator, + /// Stable facet name from [`ObjectSpaceSchema::facets`]. + pub facet: String, +} + +/// Erased preconditions for one set operation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DynamicGuard { + /// Expected durable semantic identity. + pub expected_referent: u64, + /// Runtime space in which the revision was observed. + pub expected_space: u64, + /// Expected space-local revision sequence. + pub expected_revision: u64, + /// Expected typed value after schema recovery. + pub expected_value: DynamicValue, +} + +/// One erased guarded set operation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DynamicSet { + /// Addressed facet. + pub endpoint: DynamicEndpoint, + /// Proposed value. + pub value: DynamicValue, + /// Required preconditions. + pub guard: DynamicGuard, +} + +/// Snapshot-scoped dynamic transaction request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DynamicTransaction { + /// Runtime space in which the target set was selected. + pub selection_space: u64, + /// Revision sequence at which the target set was selected. + pub selection_revision: u64, + /// Preview or commit mode. + pub mode: TransactionMode, + /// Guarded operations, applied atomically. + pub operations: Vec, +} + +/// One erased opinion in a structured explanation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DynamicOpinion { + /// Typed value after erasure. + pub value: DynamicValue, + /// Stable provenance category. + pub provenance: &'static str, + /// Revision attached to authored provenance, when present. + pub revision: Option, +} + +/// Structured dynamic value explanation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DynamicExplanation { + /// Durable semantic subject identity. + pub subject: u64, + /// Effective value. + pub value: DynamicValue, + /// Candidate opinions in typed domain strength order. + pub opinions: Vec, + /// Winning opinion index. + pub winner: usize, + /// Stable domain reason. + pub reason: &'static str, +} + +/// One effective dynamic change. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DynamicChange { + /// Durable semantic subject identity. + pub referent: u64, + /// Previous effective integer. + pub previous: i64, + /// New effective integer. + pub current: i64, +} + +/// Dynamic transaction report produced from the typed report. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DynamicTransactionReport { + /// Preview or apply mode. + pub mode: TransactionMode, + /// Validated previous revision. + pub revision_before: u64, + /// Resulting revision. + pub revision_after: u64, + /// Effective changes. + pub changes: Vec, + /// Typed undo information recovered for the dynamic boundary. + pub undo: Vec, +} + +/// Dynamic form of one typed authored-load undo record. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DynamicUndo { + /// Durable semantic subject identity. + pub referent: u64, + /// Authored value that existed before the transaction. + pub previous_authored: Option, + /// Authored value that a future guarded undo must still observe. + pub expected_authored: i64, +} + +/// Schema-backed adapter around one typed basilica host. +#[derive(Debug)] +pub struct ReferenceTool<'a> { + space: &'a mut Basilica, +} + +impl<'a> ReferenceTool<'a> { + /// Borrows a typed host through its dynamic tooling adapter. + #[must_use] + pub const fn new(space: &'a mut Basilica) -> Self { + Self { space } + } + + /// Returns the schema that governs every dynamic operation. + #[must_use] + pub const fn schema(&self) -> &'static ObjectSpaceSchema { + &BASILICA_SCHEMA + } + + /// Reads and explains an endpoint after recovering its typed schema. + pub fn read(&self, endpoint: &DynamicEndpoint) -> Result { + let endpoint = self.typed_endpoint(endpoint)?; + let explained = self.space.read_load(&endpoint).map_err(ToolError::Read)?; + let opinions = explained.opinions().iter().map(dynamic_opinion).collect(); + Ok(DynamicExplanation { + subject: explained.subject().get(), + value: DynamicValue::Integer(*explained.value()), + opinions, + winner: explained.winner(), + reason: match explained.reason() { + addressable_reference::LoadReason::AuthoredOverridesDefault => { + "authored-overrides-default" + } + addressable_reference::LoadReason::DefaultUsed => "default-used", + }, + }) + } + + /// Validates and delegates a dynamic transaction to the typed host path. + pub fn transact( + &mut self, + request: DynamicTransaction, + ) -> Result { + if request.selection_space != self.space.id().get() { + return Err(ToolError::WrongSpace { + expected: self.space.id().get(), + actual: request.selection_space, + }); + } + let mut operations = Vec::with_capacity(request.operations.len()); + for operation in &request.operations { + if operation.guard.expected_space != self.space.id().get() { + return Err(ToolError::WrongSpace { + expected: self.space.id().get(), + actual: operation.guard.expected_space, + }); + } + let endpoint = self.typed_endpoint(&operation.endpoint)?; + let value = integer(&operation.value)?; + let expected_value = integer(&operation.guard.expected_value)?; + operations.push(SetLoad::new( + endpoint, + value, + Guard::new( + FeatureId::new(operation.guard.expected_referent), + Revision::new(self.space.id(), operation.guard.expected_revision), + expected_value, + EditCapability::SetLoad, + ), + )); + } + let revision = Revision::new(self.space.id(), request.selection_revision); + let transaction = match request.mode { + TransactionMode::DryRun => Transaction::dry_run(revision, operations), + TransactionMode::Apply => Transaction::apply(revision, operations), + }; + let report = self + .space + .transact(transaction) + .map_err(ToolError::Conflict)?; + Ok(DynamicTransactionReport { + mode: report.mode(), + revision_before: report.revision_before().get(), + revision_after: report.revision_after().get(), + changes: report + .changes() + .iter() + .map(|change| DynamicChange { + referent: change.referent().get(), + previous: change.previous(), + current: change.current(), + }) + .collect(), + undo: report + .undo() + .iter() + .map(|undo| DynamicUndo { + referent: undo.referent().get(), + previous_authored: undo.previous_authored(), + expected_authored: undo.expected_authored(), + }) + .collect(), + }) + } + + fn typed_endpoint( + &self, + endpoint: &DynamicEndpoint, + ) -> Result, ToolError> { + if endpoint.facet != "load" { + return Err(ToolError::UnknownFacet(endpoint.facet.clone())); + } + if endpoint.owner.space != self.space.id().get() { + return Err(ToolError::WrongSpace { + expected: self.space.id().get(), + actual: endpoint.owner.space, + }); + } + let view = match endpoint.owner.view.as_str() { + "assembly" => BasilicaView::Assembly, + "dependency" => BasilicaView::Dependency, + _ => return Err(ToolError::UnknownView(endpoint.owner.view.clone())), + }; + let address = + AbsoluteAddress::parse(&endpoint.owner.address).map_err(ToolError::InvalidAddress)?; + let locator = Locator::exact(self.space.id(), view, address); + match self.space.resolve(&locator) { + Resolution::Resolved(location) => Ok(Endpoint::new(location, Load)), + _ => Err(ToolError::Unresolved), + } + } +} + +fn integer(value: &DynamicValue) -> Result { + match value { + DynamicValue::Integer(value) => Ok(*value), + other => Err(ToolError::TypeMismatch { + expected: DynamicKind::Integer, + actual: other.kind(), + }), + } +} + +fn dynamic_opinion(opinion: &Opinion) -> DynamicOpinion { + match opinion.provenance() { + LoadProvenance::Authored { revision } => DynamicOpinion { + value: DynamicValue::Integer(*opinion.value()), + provenance: "authored", + revision: Some(revision.get()), + }, + LoadProvenance::Default { .. } => DynamicOpinion { + value: DynamicValue::Integer(*opinion.value()), + provenance: "default", + revision: None, + }, + } +} + +/// Failure at the schema-backed dynamic boundary. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ToolError { + /// The locator named another runtime space instance. + WrongSpace { + /// Adapted space id. + expected: u64, + /// Supplied dynamic space id. + actual: u64, + }, + /// The view name does not exist in the declared schema. + UnknownView(String), + /// Text could not be parsed as a typed exact address. + InvalidAddress(AddressError), + /// The typed locator did not resolve normally. + Unresolved, + /// The facet name does not exist in the declared schema. + UnknownFacet(String), + /// A dynamic value did not match the facet schema. + TypeMismatch { + /// Declared schema kind. + expected: DynamicKind, + /// Supplied dynamic kind. + actual: DynamicKind, + }, + /// Typed endpoint reading rejected stale or invalid context. + Read(ReadError), + /// The typed guarded transaction rejected the request atomically. + Conflict(TransactionConflict), +} + +#[cfg(test)] +mod tests { + use addressable::{ + AbsoluteAddress, Endpoint, Guard, Locator, Resolution, SpaceId, Transaction, + TransactionMode, + }; + use addressable_reference::{ + Basilica, BasilicaSpace, BasilicaView, EditCapability, FeatureId, Load, LoadReason, SetLoad, + }; + + use super::{ + DynamicEndpoint, DynamicGuard, DynamicLocator, DynamicSet, DynamicTransaction, + DynamicValue, ReferenceTool, ToolError, + }; + + #[test] + fn dynamic_operation_is_equivalent_to_the_typed_path() { + let mut typed_space = Basilica::new(SpaceId::::new(1)); + let mut dynamic_space = typed_space.clone(); + let locator = Locator::exact( + typed_space.id(), + BasilicaView::Assembly, + AbsoluteAddress::parse("/basilica/nave/north_arch").expect("valid address"), + ); + let Resolution::Resolved(location) = typed_space.resolve(&locator) else { + panic!("typed location resolves"); + }; + let endpoint = Endpoint::new(location, Load); + let explained = typed_space + .read_load(&endpoint) + .expect("typed read succeeds"); + assert_eq!(explained.reason(), &LoadReason::AuthoredOverridesDefault); + typed_space + .transact(Transaction::apply( + typed_space.revision(), + [SetLoad::new( + endpoint, + 80, + Guard::new( + FeatureId::new(3), + typed_space.revision(), + 120, + EditCapability::SetLoad, + ), + )], + )) + .expect("typed operation applies"); + + let dynamic_endpoint = DynamicEndpoint { + owner: DynamicLocator { + space: dynamic_space.id().get(), + view: "assembly".into(), + address: "/basilica/nave/north_arch".into(), + }, + facet: "load".into(), + }; + let dynamic_space_id = dynamic_space.id().get(); + let mut tool = ReferenceTool::new(&mut dynamic_space); + assert_eq!(tool.schema().facets[0].name, "load"); + let before = tool.read(&dynamic_endpoint).expect("dynamic read succeeds"); + assert_eq!(before.value, DynamicValue::Integer(120)); + let report = tool + .transact(DynamicTransaction { + selection_space: dynamic_space_id, + selection_revision: 0, + mode: TransactionMode::Apply, + operations: vec![DynamicSet { + endpoint: dynamic_endpoint.clone(), + value: DynamicValue::Integer(80), + guard: DynamicGuard { + expected_referent: 3, + expected_space: dynamic_space_id, + expected_revision: 0, + expected_value: DynamicValue::Integer(120), + }, + }], + }) + .expect("dynamic operation delegates successfully"); + assert_eq!(report.revision_after, typed_space.revision().get()); + assert_eq!(report.changes[0].current, 80); + + let after = tool + .read(&dynamic_endpoint) + .expect("dynamic reread succeeds"); + let typed_locator = Locator::exact( + typed_space.id(), + BasilicaView::Assembly, + AbsoluteAddress::parse("/basilica/nave/north_arch").expect("valid address"), + ); + let Resolution::Resolved(typed_location) = typed_space.resolve(&typed_locator) else { + panic!("typed location re-resolves"); + }; + let typed_after = typed_space + .read_load(&Endpoint::new(typed_location, Load)) + .expect("typed reread succeeds"); + assert_eq!(after.value, DynamicValue::Integer(*typed_after.value())); + } + + #[test] + fn dynamic_selection_revision_cannot_cross_spaces() { + let mut space = Basilica::new(SpaceId::::new(1)); + let original = space.revision(); + let result = ReferenceTool::new(&mut space).transact(DynamicTransaction { + selection_space: 2, + selection_revision: 0, + mode: TransactionMode::Apply, + operations: vec![], + }); + + assert_eq!( + result, + Err(ToolError::WrongSpace { + expected: 1, + actual: 2, + }) + ); + assert_eq!(space.revision(), original); + } +} diff --git a/examples/addressable_tour/Cargo.toml b/examples/addressable_tour/Cargo.toml new file mode 100644 index 0000000..773cf97 --- /dev/null +++ b/examples/addressable_tour/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "addressable_tour" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Executable complete vertical slice for Addressable" +publish = false + +[dependencies] +addressable.workspace = true +addressable_reference.workspace = true +addressable_tooling.workspace = true + +[lints] +workspace = true diff --git a/examples/addressable_tour/src/main.rs b/examples/addressable_tour/src/main.rs new file mode 100644 index 0000000..17ac572 --- /dev/null +++ b/examples/addressable_tour/src/main.rs @@ -0,0 +1,237 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Executable complete vertical slice for Addressable. + +use addressable::{ + AbsoluteAddress, CyclePolicy, Deduplication, Endpoint, Guard, Locator, Pinned, Query, + Resolution, ResultOrdering, SpaceId, Transaction, TransactionMode, TraversalBudget, + VisitIdentity, +}; +use addressable_reference::{ + Basilica, BasilicaAxis, BasilicaPredicate, BasilicaSpace, BasilicaView, Catalog, CatalogSpace, + EditCapability, FeatureKind, Load, SetLoad, +}; +use addressable_tooling::{ + DynamicEndpoint, DynamicGuard, DynamicLocator, DynamicSet, DynamicTransaction, DynamicValue, + ReferenceTool, +}; + +fn main() { + let mut basilica = Basilica::new(SpaceId::::new(1)); + let catalog = Catalog::new(SpaceId::::new(2)); + + let north_locator = Locator::exact( + basilica.id(), + BasilicaView::Assembly, + AbsoluteAddress::parse("/basilica/nave/north_arch").expect("valid exact address"), + ); + let south_locator = Locator::relative( + basilica.id(), + BasilicaView::Assembly, + AbsoluteAddress::parse("/basilica/nave").expect("valid relative base"), + addressable::RelativeAddress::parse("south_arch").expect("valid relative path"), + ); + let Resolution::Resolved(north) = basilica.resolve(&north_locator) else { + panic!("north arch must resolve"); + }; + let Resolution::Resolved(south) = basilica.resolve(&south_locator) else { + panic!("south arch must resolve"); + }; + let relative_document = south_locator.to_string(); + let decoded_relative = relative_document + .parse::() + .expect("canonical relative locator must parse"); + assert_eq!( + decoded_relative, south_locator, + "relative locator serialization must round-trip" + ); + assert_eq!( + north.referent(), + south.referent(), + "shared arches must retain one semantic referent" + ); + assert_ne!( + north.occurrence(), + south.occurrence(), + "north and south appearances must remain distinct" + ); + + let pinned = Pinned::new(north_locator, *north.referent(), basilica.revision()); + let pinned_document = pinned.to_string(); + let decoded_pin = pinned_document + .parse::>() + .expect("canonical pin must parse"); + assert_eq!( + decoded_pin, pinned, + "pinned locator serialization must round-trip" + ); + assert!( + matches!(basilica.resolve_pinned(&pinned), Resolution::Resolved(_)), + "an unchanged pin must resolve normally" + ); + + let loaded_arches = Query::many(basilica.root_locator()) + .traverse(BasilicaAxis::Descendants) + .filter(BasilicaPredicate::Kind(FeatureKind::Arch)) + .filter(BasilicaPredicate::LoadAtLeast(100)) + .deduplicate(Deduplication::Occurrence) + .order(ResultOrdering::Stable) + .cycles(CyclePolicy::SkipVisited(VisitIdentity::Occurrence)) + .budget(TraversalBudget::new(8, 128, 32, 512)); + let results = basilica + .query_many(&loaded_arches) + .expect("typed assembly query succeeds"); + assert_eq!( + results.items().len(), + 2, + "occurrence deduplication must preserve both arches" + ); + + let dependency_query = Query::many(basilica.root_locator()) + .traverse(BasilicaAxis::ToView(BasilicaView::Dependency)) + .traverse(BasilicaAxis::Descendants) + .deduplicate(Deduplication::Occurrence) + .order(ResultOrdering::Stable) + .cycles(CyclePolicy::SkipVisited(VisitIdentity::Occurrence)) + .budget(TraversalBudget::new(8, 128, 32, 512)); + assert_eq!( + basilica + .query_many(&dependency_query) + .expect("cycle policy terminates") + .items() + .len(), + 3, + "dependency traversal must visit nave, arch, and vault exactly once" + ); + + let mut watch = basilica + .watch(loaded_arches.clone()) + .expect("occurrence watch starts"); + let mut replayed = watch.snapshot().clone(); + let arch = results.items()[0].clone(); + let endpoint = Endpoint::new(arch.clone(), Load); + let explained = basilica + .read_load(&endpoint) + .expect("typed load can be explained"); + let handle = basilica + .resolved_handle(&arch) + .expect("runtime handle resolves"); + assert_eq!( + *explained.value(), + 120, + "the authored load must win over the default" + ); + + let edit = SetLoad::new( + endpoint, + 80, + Guard::new( + *arch.referent(), + basilica.revision(), + *explained.value(), + EditCapability::SetLoad, + ), + ); + let preview = basilica + .transact(Transaction::dry_run(basilica.revision(), [edit.clone()])) + .expect("dry run validates"); + assert_eq!( + preview.mode(), + TransactionMode::DryRun, + "preview must not be reported as an apply" + ); + let applied = basilica + .transact(Transaction::apply(basilica.revision(), [edit])) + .expect("guarded transaction applies"); + assert_eq!(applied.changes().len(), 1, "one referent value must change"); + assert_eq!( + applied.undo().len(), + 1, + "the applied change must carry undo information" + ); + + let delta = watch.poll(&basilica).expect("watch advances coherently"); + replayed.apply(&delta).expect("delta replays"); + assert_eq!( + &replayed, + watch.snapshot(), + "delta replay must equal full recomputation" + ); + assert!( + watch.snapshot().entries().is_empty(), + "both occurrences must leave the load-filtered query" + ); + + let correspondence = basilica.correspond_to_catalog(*arch.referent(), &catalog); + assert!( + correspondence.is_ambiguous(), + "one shared feature must map to two catalog results" + ); + assert_eq!( + correspondence.targets().len(), + 2, + "both result occurrences must retain correspondence evidence" + ); + + let dynamic_endpoint = DynamicEndpoint { + owner: DynamicLocator { + space: basilica.id().get(), + view: "assembly".into(), + address: "/basilica/nave/north_arch".into(), + }, + facet: "load".into(), + }; + let dynamic_space = basilica.id().get(); + let current_revision = basilica.revision().get(); + let dynamic_report = { + let mut tool = ReferenceTool::new(&mut basilica); + assert_eq!( + tool.schema().name, + "addressable.reference.basilica/v1", + "dynamic calls must be governed by the declared schema" + ); + assert_eq!( + tool.read(&dynamic_endpoint) + .expect("dynamic explanation succeeds") + .value, + DynamicValue::Integer(80), + "dynamic reads must agree with the typed effective value" + ); + tool.transact(DynamicTransaction { + selection_space: dynamic_space, + selection_revision: current_revision, + mode: TransactionMode::Apply, + operations: vec![DynamicSet { + endpoint: dynamic_endpoint, + value: DynamicValue::Integer(120), + guard: DynamicGuard { + expected_referent: arch.referent().get(), + expected_space: dynamic_space, + expected_revision: current_revision, + expected_value: DynamicValue::Integer(80), + }, + }], + }) + .expect("dynamic operation delegates to typed transaction") + }; + assert_eq!( + dynamic_report.changes[0].current, 120, + "dynamic set must delegate to the typed transaction" + ); + assert_eq!( + dynamic_report.undo.len(), + 1, + "dynamic callers must receive typed undo information" + ); + + println!( + "Addressable slice complete: referent {}, occurrences 2, edge ids {}, slot {}, revisions 0→{}, catalog targets {}, dynamic revision {}", + arch.referent().get(), + basilica.edge_ids().len(), + handle.handle().get(), + applied.revision_after().get(), + correspondence.targets().len(), + dynamic_report.revision_after, + ); +} diff --git a/taplo.toml b/taplo.toml new file mode 100644 index 0000000..ad371e4 --- /dev/null +++ b/taplo.toml @@ -0,0 +1,25 @@ +exclude = ["target/**"] + +[formatting] +align_comments = false +array_auto_expand = false +array_auto_collapse = false +reorder_keys = false +indent_string = " " +column_width = 140 + +[[rule]] +include = ["**/Cargo.toml"] +keys = ["dependencies", "dev-dependencies", "workspace.dependencies", "workspace.lints"] + +[rule.formatting] +reorder_arrays = true +reorder_keys = true +reorder_inline_tables = true + +[[rule]] +include = ["**/Cargo.toml"] +keys = ["workspace"] + +[rule.formatting] +reorder_arrays = true From 48dc395d0966483a18855b714de2fa3393027037 Mon Sep 17 00:00:00 2001 From: Bruce Mitchener Date: Mon, 24 Aug 2026 15:11:22 +0700 Subject: [PATCH 4/4] Add CI and document the completed vertical slice --- .github/copyright.sh | 23 +++ .github/workflows/ci.yml | 169 +++++++++++++++++ .typos.toml | 8 + LICENSE-APACHE | 176 ++++++++++++++++++ LICENSE-MIT | 19 ++ README.md | 130 +++++++++++-- STATUS.md | 154 +++++++++------ docs/ARCHITECTURE.md | 15 +- docs/CONVENTIONS.md | 5 +- docs/MIGRATION.md | 59 ++++++ ...01-initial-workspace-and-vertical-slice.md | 49 +++-- docs/plans/0001-complete-vertical-slice.md | 53 +++--- 12 files changed, 742 insertions(+), 118 deletions(-) create mode 100755 .github/copyright.sh create mode 100644 .github/workflows/ci.yml create mode 100644 .typos.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 docs/MIGRATION.md diff --git a/.github/copyright.sh b/.github/copyright.sh new file mode 100755 index 0000000..c8a24d8 --- /dev/null +++ b/.github/copyright.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# If there are new files with headers that cannot match the conditions here, +# then the files can be ignored by an additional glob argument via the -g flag. +# For example: +# -g "!src/special_file.rs" +# -g "!src/special_directory" + +# Check all the standard Rust source files. +output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Addressable Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT$\n\n" --files-without-match --multiline -g "*.rs" .) + +if [ -n "$output" ]; then + echo -e "The following files lack the correct copyright header:\n" + echo "$output" + echo -e "\n\nPlease add the following header:\n" + echo "// Copyright $(date +%Y) the Addressable Authors" + echo "// SPDX-License-Identifier: Apache-2.0 OR MIT" + echo -e "\n... rest of the file ...\n" + exit 1 +fi + +echo "All files have correct copyright headers." +exit 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..efc74bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,169 @@ +env: + # Keep the minimum version in sync with workspace.package.rust-version. + RUST_MIN_VER: "1.88" + RUST_STABLE_VER: "1.97" + +name: CI + +on: + pull_request: + merge_group: + push: + branches: + - main + +jobs: + fmt: + name: formatting and repository policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_STABLE_VER }} + components: rustfmt + + - name: cargo fmt + run: cargo fmt --all --check + + - name: install Taplo + uses: uncenter/setup-taplo@09968a8ae38d66ddd3d23802c44bf6122d7aa991 # v1 + with: + version: "0.9.3" + + - name: taplo fmt + run: taplo fmt --check --diff + + - name: install ripgrep + run: | + sudo apt update + sudo apt install ripgrep + + - name: check copyright headers + run: bash .github/copyright.sh + + clippy: + name: cargo clippy + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v6 + + - name: install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_STABLE_VER }} + components: clippy + + - name: restore cache + uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.event_name != 'merge_group' }} + + - name: cargo clippy + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + + portable-core: + name: portable core + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_STABLE_VER }} + targets: x86_64-unknown-none,wasm32-unknown-unknown + + - name: restore cache + uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.event_name != 'merge_group' }} + + - name: check bare-metal core + run: cargo check -p addressable --locked --target x86_64-unknown-none + + - name: check WebAssembly core + run: cargo check -p addressable --locked --target wasm32-unknown-unknown + + test: + name: cargo test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v6 + + - name: install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_STABLE_VER }} + + - name: restore cache + uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.event_name != 'merge_group' }} + + - name: cargo test + run: cargo test --workspace --all-features --locked + + - name: cargo test docs + run: cargo test --doc --workspace --all-features --locked + + msrv: + name: Rust 1.88 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: install MSRV toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_MIN_VER }} + targets: x86_64-unknown-none + + - name: restore cache + uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.event_name != 'merge_group' }} + + - name: check workspace + run: cargo check --workspace --all-targets --all-features --locked + + - name: check bare-metal core + run: cargo check -p addressable --locked --target x86_64-unknown-none + + docs: + name: rustdoc + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_STABLE_VER }} + + - name: restore cache + uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.event_name != 'merge_group' }} + + - name: cargo doc + run: cargo doc --workspace --all-features --locked --no-deps --document-private-items + env: + RUSTDOCFLAGS: "-D warnings" + + typos: + name: typos + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: check typos + uses: crate-ci/typos@v1.46.0 diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 0000000..c294bf5 --- /dev/null +++ b/.typos.toml @@ -0,0 +1,8 @@ +# See https://github.com/crate-ci/typos/blob/master/docs/reference.md + +[default.extend-words] +referent = "referent" + +[files] +ignore-hidden = false +extend-exclude = ["/.git"] diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..9cf1062 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ffd8a5d..9896d0c 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,121 @@ # Addressable -Typed addressing, navigation, observation, explanation, and guarded editing for -structured object spaces. +Addressable is the typed substrate for locating, navigating, observing, +explaining, and safely modifying things in structured object spaces. -Addressable is intended to preserve the distinctions between semantic identity, -contextual occurrence, exact address, general query, typed endpoint, revision, -and efficient runtime handle across trees, DAGs, graphs, hypergraphs, composed -models, and live user interfaces. +It preserves distinctions that string paths and runtime handles usually erase: -It is being developed as shared infrastructure for systems including Setout, -Layerstack, Understory/Overstory, Portolan, Imaging, and Exedra integrations, -without collapsing those domains into a universal graph or value model. +- a referent is the semantic thing; +- an occurrence is where that thing appears in a particular view; +- an endpoint is a typed facet on a located owner; +- a space-typed revision says which instance and state were resolved; +- a resolved handle is a runtime capability, never durable identity. -The project is at its architectural bootstrap. See: +The same arch referent can therefore appear as north and south assembly +occurrences without being duplicated. A caller can query both, deduplicate by +referent when appropriate, read and explain a typed load endpoint, apply a +guarded edit, and observe a coherent live delta. -- [`MANDATE.md`](MANDATE.md) for purpose, scope, and delegated authority; -- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the semantic nucleus; -- [`STATUS.md`](STATUS.md) for the exact resumption point. +## Workspace -The intended license is the standard forest-rs dual Apache-2.0/MIT arrangement, -subject to confirmation against the local forest-rs project tenets before the -license and crate metadata are added. +| Crate | Boundary | +|---|---| +| `addressable` | Dependency-free `no_std + alloc` vocabulary, structured addresses, query IR, live deltas, guards, and correspondence | +| `addressable_reference` | `std` scanning basilica and catalog spaces exercising the complete lifecycle | +| `addressable_tooling` | Schema-backed erased adapter that delegates to the typed reference API | +| `addressable_tour` | Separate executable proof; no example-only dependencies enter production crates | + +Dependencies flow in one direction: + +```text +addressable <- addressable_reference <- addressable_tooling <- addressable_tour +``` + +## Typed use + +```rust +use addressable::{ + CyclePolicy, Deduplication, Endpoint, Guard, Query, SpaceId, Transaction, + VisitIdentity, +}; +use addressable_reference::{ + Basilica, BasilicaAxis, BasilicaPredicate, BasilicaSpace, EditCapability, + FeatureKind, Load, SetLoad, +}; + +let mut space = Basilica::new(SpaceId::::new(1)); +let query = Query::many(space.root_locator()) + .traverse(BasilicaAxis::Descendants) + .filter(BasilicaPredicate::Kind(FeatureKind::Arch)) + .filter(BasilicaPredicate::LoadAtLeast(100)) + .deduplicate(Deduplication::Occurrence) + .cycles(CyclePolicy::SkipVisited(VisitIdentity::Occurrence)); + +let mut watch = space.watch(query.clone()).expect("watch starts"); +let arch = space + .query_many(&query) + .expect("query succeeds") + .items()[0] + .clone(); +let endpoint = Endpoint::new(arch.clone(), Load); +let explained = space.read_load(&endpoint).expect("load reads"); +let edit = SetLoad::new( + endpoint, + 80, + Guard::new( + *arch.referent(), + space.revision(), + *explained.value(), + EditCapability::SetLoad, + ), +); + +let preview = space + .transact(Transaction::dry_run(space.revision(), [edit.clone()])) + .expect("dry run validates"); +let applied = space + .transact(Transaction::apply(space.revision(), [edit])) + .expect("edit applies"); +let delta = watch.poll(&space).expect("watch advances"); +``` + +The full tour also resolves exact, relative, and pinned locators; crosses +explicitly into a cyclic dependency view; replays the live delta; maps one arch +referent to two catalog results with evidence; and performs an equivalent +guarded operation through the dynamic schema boundary: + +```sh +cargo run -p addressable_tour +``` + +## Semantic contracts + +- Text is parsed into validated segmented addresses. It is not the in-memory + location representation. +- Query cardinality is visible in `One`, `Optional`, and `Many` query types. + Their marker trait is sealed; ordering, deduplication, cycle behavior, and + traversal budgets are explicit. +- Pinned resolution reports stale, moved, or rebound outcomes instead of + silently accepting a different referent. +- Guarded transactions validate every operation before applying any operation. +- Replaying a query delta produces the same snapshot as full recomputation; + another space or live-query stream is rejected atomically. +- Correspondence preserves one-to-many mappings and provenance, and composed + mapping legs cannot disagree about their connecting source. +- Dynamic tooling recovers a declared schema and uses the same typed guarded + operations as Rust callers. + +Addressable does not own consumer world state, a universal graph or value enum, +one storage engine, domain composition rules, or a privileged agent mutation +path. + +See [`MANDATE.md`](MANDATE.md) for the durable purpose, +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the mature design target, +[`docs/adr/0001-initial-workspace-and-vertical-slice.md`](docs/adr/0001-initial-workspace-and-vertical-slice.md) +for the initial crate decision, and [`STATUS.md`](STATUS.md) for current state. +See [`docs/MIGRATION.md`](docs/MIGRATION.md) when updating code written against +the earlier bootstrap draft. + +Addressable is available under the terms of either the +[Apache License 2.0](LICENSE-APACHE) or [MIT license](LICENSE-MIT), at your +option. diff --git a/STATUS.md b/STATUS.md index 834d327..44259df 100644 --- a/STATUS.md +++ b/STATUS.md @@ -2,69 +2,109 @@ ## Current state -The GitHub repository was created on 2026-08-24 with an empty `README.md` on -`main`. This bootstrap material was prepared from the originating ChatGPT Work -conversation before moving development into a local Codex or local Work session. - -No Rust workspace, crate layout, public API, CI configuration, license files, or -release policy has been committed yet. That is intentional: the cloud session -could inspect GitHub but could not access the owner's local forest-rs checkout -and old tenets. - -## Why this branch exists - -The originating conversation developed a mature architectural direction for -Addressable and then encountered a product boundary: a cloud Work conversation -could continue on desktop, but could not see `/Users/bruce/Development/forest-rs` -or become the same repository-bound local Codex session. - -This branch is the durable bridge across that brief break. The local session is -not expected to reconstruct intent from chat history. - -## First actions for the local session - -1. Open or clone `forest-rs/addressable` under - `/Users/bruce/Development/forest-rs/addressable` and check out this branch. -2. Read `AGENTS.md`, `MANDATE.md`, and `docs/ARCHITECTURE.md` completely. -3. Discover and read applicable ancestor instructions and the old forest-rs - tenets. Search the local forest-rs tree rather than assuming they are public - or current. -4. Inspect representative current CI, metadata, lint, formatting, licensing, - MSRV, feature, and `no_std` practice in sibling projects. At minimum compare - `exedra`, `portolan`, `layerstack`, `understory`, `overstory`, and `inkstone`. -5. Record the resulting project conventions and any conflict with this bootstrap - architecture before scaffolding. -6. Decide the smallest honest initial crate/workspace boundary that supports the - complete vertical slice in `docs/ARCHITECTURE.md`. -7. Implement, test, and document the vertical slice. Use a branch and keep - changes reversible. Do not merge or publish without the owner's decision. - -Useful local discovery commands include: +The complete initial vertical slice was implemented on 2026-08-24 on branch +`codex/bootstrap-addressable`. The branch has been pushed for review; it has +not been merged, tagged, or published. + +The workspace contains four underscore-named packages: + +- `addressable`: dependency-free, always `no_std + alloc` semantic vocabulary; +- `addressable_reference`: a `std` scanning basilica host and second catalog + space; +- `addressable_tooling`: schema-backed dynamic adaptation through typed host + calls; +- `addressable_tour`: a separate executable proof crate. + +The crate decision, fences, invariants, and resolved bootstrap questions are in +[`docs/adr/0001-initial-workspace-and-vertical-slice.md`](docs/adr/0001-initial-workspace-and-vertical-slice.md). +The local forest-rs convention survey is in +[`docs/CONVENTIONS.md`](docs/CONVENTIONS.md). + +## What is real + +The reference slice exercises every lifecycle item required by the initial +architecture: + +1. One arch referent has distinct north and south assembly occurrences. +2. Exact, relative, and pinned locators have canonical round-trip documents and + resolve with rich outcomes; pinned rebinding has a regression test. +3. Typed queries cross explicitly between assembly and dependency views. Query + cardinality is restricted to the sealed `One`, `Optional`, and `Many` + markers. Ordering, deduplication, cycle policy, and four work budgets are + explicit. The dependency graph contains a real cycle. +4. A typed `Load` endpoint returns effective value, alternatives, provenance, + and a domain-owned winning reason. +5. A scanning watch maintains occurrence-identified query results under an + explicit host-assigned live-query identity. +6. Atomic guarded transactions support dry-run and apply, require referent, + revision, value, and capability preconditions, and return undo information. +7. Query deltas are replayed and compared with full recomputation. Replay + rejects another space, live-query stream, or cross-space transition without + partial effect. +8. One arch referent maps to two independently addressable catalog results while + retaining correspondence evidence. +9. The dynamic adapter declares its view/facet/value schema, reconstructs typed + endpoints and guards, and delegates to the same transaction method. Typed and + dynamic operation equivalence is tested, including undo data. + +The tour runs all nine points through public APIs: ```sh -rg --files /Users/bruce/Development/forest-rs \ - | rg '(^|/)(AGENTS\.md|.*[Tt][Ee][Nn][Ee][Tt].*|ci\.yml|Cargo\.toml|taplo\.toml|clippy\.toml)$' +cargo run -p addressable_tour --locked +``` + +## Deliberately simple execution + +The contracts are real; the first execution is intentionally modest: + +- resolution and query execution scan small vectors; +- watches recompute synchronously when polled; +- the reference mutation vocabulary currently authors one integer load facet; +- the first watcher supports occurrence identity only and rejects other live + identities explicitly; +- the catalog correspondence is in-memory and deterministic; +- the tooling schema is reference-specific until a second real adapter proves a + generic protocol; +- there is no textual query language, async runtime, persistent journal, + production index, or consumer adapter yet. -rg -n -i 'tenet|no_std|msrv|wasm32v1-none|cargo hack|cargo semver|rustdoc' \ - /Users/bruce/Development/forest-rs +These are replaceable host choices, not placeholders in the core semantic +types. No production or development dependencies were added. + +## Validation evidence + +The repository is green under the local definition of done: + +```sh +typos +taplo fmt --check --diff +cargo fmt --all --check +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo test --workspace --all-features --locked +RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --locked --no-deps --document-private-items +cargo check -p addressable --locked --target x86_64-unknown-none +cargo check -p addressable --locked --target wasm32-unknown-unknown +cargo +1.88 check --workspace --all-targets --all-features --locked +cargo +1.88 check -p addressable --locked --target x86_64-unknown-none +cargo run -p addressable_tour --locked ``` -Prefer narrower searches after locating likely files; the tree contains many -repositories and generated build output may be large. +Results: 23 unit tests and 4 doctests pass; strict Clippy and warning-denied +rustdoc pass; native stable, Rust 1.88, bare-metal `no_std`, and WebAssembly +core checks pass; repository formatting, typo, SPDX-header, and whitespace +checks pass. -## Important unresolved decisions +## Repository decisions retained by the owner -- Exact crate/module boundaries after applying local forest-rs conventions. -- The smallest sufficient type representation for owned and borrowed names, - paths, locations, and occurrences. -- Whether query cardinality belongs in static types, builders, execution - methods, or a combination. -- Revision and space identity requirements in `no_std` contexts. -- The division between shared explanation vocabulary and domain-defined - explanation payloads. -- The erased/schema boundary needed by Portolan and agents. -- Which consumer provides the first real adapter after the reference space. +All packages remain `publish = false`. No merge, release, publication, or +sibling-repository edit was performed. The repository includes the standard +forest-rs Apache-2.0 and MIT license texts matching its workspace metadata. -The implementation agent owns these choices within the mandate and should use -evidence to decide rather than asking the owner to settle routine architecture. +## Next architectural evidence +The next meaningful step is one real consumer adapter, selected by consumer +need rather than by expanding the generic core speculatively. A consumer should +reuse the existing durable vocabulary while supplying its own typed identities, +views, axes, predicates, endpoints, values, provenance, and evaluator. If that +adapter reveals a genuine shared host trait or dynamic schema protocol, record +the evidence in a new ADR before moving ownership between crates. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a97f3b..089ca4d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,6 +3,12 @@ This document records the shape Addressable is trying to preserve before local implementation work begins. It is a starting constitution, not a frozen API. +The initial crate boundary and complete executable slice are now implemented as +recorded in +[`adr/0001-initial-workspace-and-vertical-slice.md`](adr/0001-initial-workspace-and-vertical-slice.md). +The mature architecture below remains the design target; the scanning host is a +conformance execution, not a reduction of the mandate to its first evaluator. + ## 1. Vocabulary ### Address space @@ -134,6 +140,11 @@ A live query maintains located results across revisions and emits deltas such as additions, removals, updates, moves, and rebindings. Delta semantics must say whether identity is by occurrence, referent, or result-entry identity. +Space identity and live-query identity are independent replay preconditions. A +numerically equal revision in another space is not the same revision, and two +subscriptions in one space are not interchangeable merely because their +current entries happen to match. + Important law: applying a coherent delta stream to the previous result set must produce the same observable result as recomputing the query at the new revision. @@ -173,7 +184,8 @@ Layerstack authored spec -> Portolan live result and affordance ``` -Composition of correspondences must preserve ambiguity and provenance. +Composition of correspondences must preserve ambiguity and provenance, and +each second mapping leg must begin at the target produced by the first leg. ## 9. Typed and dynamic boundaries @@ -241,4 +253,3 @@ whole lifecycle: That prevents later features from discovering that the foundational identity model was too small while keeping the first implementation finite. - diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 78cafc6..a05f765 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -23,9 +23,8 @@ is owned by the durable plan and ADR in `docs/`. - Rust 1.88 is the conservative shared MSRV. Newer siblings have moved to 1.92, but Layerstack, Understory, Portolan, and Inkstone still prove 1.88. - Cargo resolver 2. -- The intended repository metadata remains `Apache-2.0 OR MIT`, as already - stated in the bootstrap README. This slice does not add or alter license - texts. +- Use the standard forest-rs `Apache-2.0 OR MIT` expression, root license + texts, and Rust-source copyright-header gate. - Internal dependencies are centralized in `[workspace.dependencies]`, use `default-features = false`, and carry versions when publication is intended. - Initial packages are `publish = false`; publication is an owner decision. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..2aa1a26 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,59 @@ +# Migration from the bootstrap API + +The initial slice deliberately tightened several public types before any crate +was published. Code written against the earlier bootstrap draft should make the +following mechanical changes. + +## Space-typed revisions + +`Revision` is now `Revision` and contains its `SpaceId`: + +```rust +let initial = Revision::initial(space_id); +let later = Revision::new(space_id, 4); +``` + +Replace `Revision::INITIAL` with `Revision::initial(space_id)` and replace +`Revision::new(sequence)` with `Revision::new(space_id, sequence)`. +`Location::new` and `ResolvedHandle::new` no longer accept a separate space id; +they derive it from their revision. `Resolution`, `Guard`, and `Transaction` +gain the corresponding space marker parameter. + +Pinned locator documents now serialize both the expected revision's runtime +space and sequence. Documents produced by the earlier unpublished bootstrap +format should be reparsed and re-emitted by an adapter that supplies the space +recorded in their embedded locator. + +## Live-query scope + +`QuerySnapshot`, `QueryDelta`, and `DeltaError` gain a space marker parameter. +Snapshot and delta constructors also take a host-assigned `LiveQueryId`. +The reference `Basilica::watch` method now takes `&mut self` so it can allocate +that local id. Replay rejects mismatched live-query ids, mismatched space +revisions, and deltas that transition between spaces. + +## Closed cardinality markers + +`Query` cardinality must implement the sealed `Cardinality` trait. Use `One`, +`Optional`, or `Many`; custom marker types are no longer accepted. Generic code +can inspect `C::KIND` or `query.cardinality()`. + +## Correspondence composition + +The `Correspondence::compose` callback now returns an iterator of +`CorrespondenceTarget` values rather than another `Correspondence`: + +```rust,ignore +first.compose(|source| { + lookup(source).map(|target| [CorrespondenceTarget::new(target, evidence)]) +}) +``` + +The callback already receives the connecting source, so removing the redundant +second source makes disagreement between composition legs unrepresentable. + +## Dynamic guarded requests + +`DynamicTransaction` adds `selection_space`, and `DynamicGuard` adds +`expected_space`. Set both to the runtime space id from the dynamic locator. +The tooling adapter validates them before reconstructing typed revisions. diff --git a/docs/adr/0001-initial-workspace-and-vertical-slice.md b/docs/adr/0001-initial-workspace-and-vertical-slice.md index 288fe33..ee4a567 100644 --- a/docs/adr/0001-initial-workspace-and-vertical-slice.md +++ b/docs/adr/0001-initial-workspace-and-vertical-slice.md @@ -28,16 +28,19 @@ does not provide reusable production behavior. ## Invariants -1. Space, referent, occurrence, endpoint, revision, and runtime handle identity - remain distinct types. +1. Space, referent, occurrence, endpoint, revision, live-query, and runtime + handle identity remain distinct types. Every public revision value carries + its typed runtime space. 2. Exact and relative text parse into structured addresses; strings are never the resolved representation. 3. A pinned locator never returns ordinary success for a different referent. 4. Query cardinality is visible in the query type, while ordering, deduplication, cycle behavior, and budgets remain explicit values. -5. Watch deltas replay to the same observable snapshot as full recomputation. +5. Watch deltas replay to the same observable snapshot as full recomputation + and cannot cross spaces or live-query streams. 6. Guarded transactions validate atomically and expose dry-run and undo data. -7. Correspondence preserves one-to-many outcomes and evidence. +7. Correspondence preserves one-to-many outcomes and evidence; composition + cannot substitute an unrelated source for either mapping leg. 8. Dynamic reads and writes recover a declared schema and delegate to the same typed host methods used by Rust callers. 9. Runtime-local handles cannot be formatted or parsed as durable addresses. @@ -75,19 +78,38 @@ tooling crates are honestly `std`-dependent. All packages begin unpublished. ## Cardinality decision -`One`, `Optional`, and `Many` are marker types on `Query`. Reference execution -methods accept the corresponding query type and return the corresponding -shape. Dynamic tooling may erase that marker only after validating its schema. -This combines compile-time call-site guidance with a representable runtime -contract. +`One`, `Optional`, and `Many` are marker types on `Query` and implement a sealed +`Cardinality` trait. Every accepted marker therefore has a defined +`CardinalityKind`. Reference execution methods accept the corresponding query +type and return the corresponding shape. Dynamic tooling may erase that marker +only after validating its schema. This combines compile-time call-site guidance +with a representable runtime contract. ## Revision and space identity decision `SpaceId` is a caller/host-assigned typed `u64`; it does not require a global -allocator or atomics. `Revision` is a local monotonic value meaningful only -with its space. Locations and resolved handles carry both. Durable addresses -carry a space marker at compile time, while locators carry the runtime space -identity required when several instances coexist. +allocator or atomics. `Revision` contains both that runtime space identity +and a local monotonic sequence, so a naked or cross-space revision cannot enter +the public typed API. Locations and resolved handles derive their owning space +from that revision instead of storing a second potentially inconsistent copy. +Durable addresses carry a space marker at compile time, while locators carry +the runtime space identity required when several instances coexist. + +## Live-query identity decision + +`LiveQueryId` is host-assigned within one typed space and does not prescribe +an allocator. Every `QuerySnapshot` and `QueryDelta` carries both the live-query +id and a `Revision`. Delta construction and replay reject another live-query +stream, another space, and transitions whose start and end revisions belong to +different spaces before changing a snapshot. + +## Correspondence composition decision + +The second-leg callback receives the exact first-leg target and returns only +the evidence-bearing targets that continue from it. It does not return another +`Correspondence` with a redundant source field. A mismatched second-leg source +is therefore unrepresentable while multiplicity and both evidence legs remain +preserved. ## Explanation and erasure decision @@ -105,3 +127,4 @@ or reference storage. once a second real adapter proves its common shape. - Async runtimes, serialization frameworks, and hash maps are not dependencies of the nucleus. +- The bootstrap API migration is recorded in [`../MIGRATION.md`](../MIGRATION.md). diff --git a/docs/plans/0001-complete-vertical-slice.md b/docs/plans/0001-complete-vertical-slice.md index aa7d178..2ba8813 100644 --- a/docs/plans/0001-complete-vertical-slice.md +++ b/docs/plans/0001-complete-vertical-slice.md @@ -12,7 +12,7 @@ correspondence; and an equivalent schema-backed dynamic operation. - A production graph database, query parser, async runtime, or incremental index. - Consumer-specific Setout, Layerstack, UI, or retrieval adapters. -- Stable publication promises, release artifacts, or licensing changes. +- Stable publication promises or release artifacts. - Performance claims before a consumer workload exists. ## Public call-site target @@ -21,33 +21,31 @@ The API should make the semantic choices visible without exposing evaluator plumbing: ```rust,ignore -let mut basilica = Basilica::new(SpaceId::new(1)); -let root = Locator::exact( - basilica.id(), - BasilicaView::Assembly, - AbsoluteAddress::parse("/basilica")?, -); - -let query = Query::many(root) +let mut basilica = Basilica::new(SpaceId::::new(1)); +let query = Query::many(basilica.root_locator()) .traverse(BasilicaAxis::Descendants) .filter(BasilicaPredicate::LoadAtLeast(100)) .deduplicate(Deduplication::Occurrence) - .order(Ordering::Stable) + .order(ResultOrdering::Stable) .cycles(CyclePolicy::SkipVisited(VisitIdentity::Occurrence)) .budget(TraversalBudget::new(8, 128, 32, 512)); let mut watch = basilica.watch(query.clone())?; let arch = basilica.query_many(&query)?.items()[0].clone(); -let endpoint = Endpoint::new(arch, Load); +let endpoint = Endpoint::new(arch.clone(), Load); let explained = basilica.read_load(&endpoint)?; -let edit = SetLoad::new(endpoint, 80, Guard::at( - explained.subject(), +let edit = SetLoad::new(endpoint, 80, Guard::new( + *arch.referent(), basilica.revision(), - explained.value(), + *explained.value(), + EditCapability::SetLoad, )); -let preview = basilica.transact(Transaction::dry_run([edit.clone()]))?; -let applied = basilica.transact(Transaction::apply([edit]))?; +let preview = basilica.transact(Transaction::dry_run( + basilica.revision(), + [edit.clone()], +))?; +let applied = basilica.transact(Transaction::apply(basilica.revision(), [edit]))?; let delta = watch.poll(&basilica)?; ``` @@ -81,18 +79,19 @@ after schema validation. - **Partial mutation:** validate every operation against one snapshot before applying any change; test a failing multi-operation transaction. - **Delta drift:** replay every emitted delta and compare it with full query - recomputation. + recomputation; reject another space, live-query stream, or cross-space + transition before replay. - **Dependency creep:** use only `core`, `alloc`, and `std` in this slice. ## Validation checklist -- [ ] `typos` -- [ ] `taplo fmt --check --diff` -- [ ] `cargo fmt --all --check` -- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- [ ] `cargo test --workspace --all-features` -- [ ] warning-denied rustdoc -- [ ] `x86_64-unknown-none` core check -- [ ] `wasm32-unknown-unknown` core check -- [ ] Rust 1.88 workspace check -- [ ] executable tour run +- [x] `typos` +- [x] `taplo fmt --check --diff` +- [x] `cargo fmt --all --check` +- [x] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [x] `cargo test --workspace --all-features` +- [x] warning-denied rustdoc +- [x] `x86_64-unknown-none` core check +- [x] `wasm32-unknown-unknown` core check +- [x] Rust 1.88 workspace check +- [x] executable tour run