diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efc74bf..220a1d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,10 +85,10 @@ jobs: save-if: ${{ github.event_name != 'merge_group' }} - name: check bare-metal core - run: cargo check -p addressable --locked --target x86_64-unknown-none + run: cargo check -p addressable -p addressable_tree --locked --target x86_64-unknown-none - name: check WebAssembly core - run: cargo check -p addressable --locked --target wasm32-unknown-unknown + run: cargo check -p addressable -p addressable_tree --locked --target wasm32-unknown-unknown test: name: cargo test @@ -136,7 +136,7 @@ jobs: 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 + run: cargo check -p addressable -p addressable_tree --locked --target x86_64-unknown-none docs: name: rustdoc diff --git a/Cargo.lock b/Cargo.lock index 99dd7aa..fa3c9a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,7 @@ name = "addressable_reference" version = "0.1.0" dependencies = [ "addressable", + "addressable_tree", ] [[package]] @@ -29,3 +30,10 @@ dependencies = [ "addressable_reference", "addressable_tooling", ] + +[[package]] +name = "addressable_tree" +version = "0.1.0" +dependencies = [ + "addressable", +] diff --git a/Cargo.toml b/Cargo.toml index 00e3a6e..a88c7a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/addressable", "crates/addressable_reference", "crates/addressable_tooling", + "crates/addressable_tree", "examples/addressable_tour", ] @@ -18,6 +19,7 @@ repository = "https://github.com/forest-rs/addressable" 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" } +addressable_tree = { path = "crates/addressable_tree", version = "0.1.0" } [workspace.lints] # LINEBENDER LINT SET - Cargo.toml - v8 diff --git a/PLANS.md b/PLANS.md new file mode 100644 index 0000000..492b764 --- /dev/null +++ b/PLANS.md @@ -0,0 +1,40 @@ +# Addressable tree runtime consumer slice + +## Goal + +Turn the first real Exedra consumer into subtraction by moving reusable rooted +tree resolution and query execution into a small `no_std + alloc` +`addressable_tree` crate. Exedra should retain its storage and domain policies +while deleting its custom path type, recursive path lookup, and consumer-local +assembly-query executor. Preserve the revision clock across extraction, and +make in-place commits the normal mutation path. + +## Non-goals + +- A compulsory storage engine or index. +- Generic endpoint value or mutation traits before a second domain proves them. +- Async execution, persistence, or a textual query language. + +## Steps + +1. Define a host-owned node projection trait and reusable tree runtime. +2. Prove exact, relative, pinned, cardinality, budget, deduplication, handle, + suspend/resume, and in-place commit behavior in tests and rustdoc. +3. Make the Basilica assembly projection a second `TreeHost`; retain its + dependency-specific evaluator only for graph and cross-view axes. +4. Replace Exedra's `InstancePath` machinery with structured Addressable exact + addresses and a host-owned index. +5. Implement the small tree projection in `exedra_assembly`, retain its material + explanation/edit policy, and remove Basilica-specific resolution/selection + helpers. +6. Validate and submit the Addressable and Exedra changes as separate PRs. + +## Risks + +- A trait shaped too narrowly around Exedra. Prove the assembly seam against + both Exedra and the Basilica reference domain while leaving Basilica's graph + axes domain-owned. +- Runtime mutation bypassing revisions. Expose immutable host access, preserve + the clock on extraction, and provide an in-place commit that advances once. +- Treating exact address text as domain storage. Hosts store structured + `AbsoluteAddress` values; string forms remain serialization only. diff --git a/README.md b/README.md index 98842a3..8be9f16 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ guarded edit, and observe a coherent live delta. | Crate | Boundary | |---|---| | `addressable` | Dependency-free `no_std + alloc` vocabulary, structured addresses, query IR, live deltas, guards, and correspondence | +| `addressable_tree` | Reusable `no_std + alloc` resolution and query execution over host-owned rooted trees | | `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 | @@ -28,7 +29,7 @@ guarded edit, and observe a coherent live delta. Dependencies flow in one direction: ```text -addressable <- addressable_reference <- addressable_tooling <- addressable_tour +addressable <- addressable_tree <- addressable_reference <- addressable_tooling <- addressable_tour ``` ## Typed use @@ -120,7 +121,10 @@ 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. +for the initial crate decision, +[`docs/adr/0002-tree-runtime-from-exedra.md`](docs/adr/0002-tree-runtime-from-exedra.md) +for the consumer-derived tree runtime, and [`STATUS.md`](STATUS.md) for current +state. See [`docs/MIGRATION.md`](docs/MIGRATION.md) when updating code written against the earlier bootstrap draft. diff --git a/STATUS.md b/STATUS.md index 38c1f46..c2d2b1d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -5,9 +5,11 @@ The complete initial vertical slice landed on `main` on 2026-08-24. No crate has been tagged or published. -The workspace contains four packages: +The workspace contains five packages: - `addressable`: dependency-free, always `no_std + alloc` semantic vocabulary; +- `addressable_tree`: reusable `no_std + alloc` resolution and typed query + execution over host-owned rooted trees; - `addressable_reference`: a `std` scanning basilica host and second catalog space; - `addressable_tooling`: schema-backed dynamic adaptation through typed host @@ -16,6 +18,8 @@ The workspace contains four packages: 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 first consumer-derived execution boundary is in +[`docs/adr/0002-tree-runtime-from-exedra.md`](docs/adr/0002-tree-runtime-from-exedra.md). The local forest-rs convention survey is in [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md). @@ -30,7 +34,8 @@ architecture: 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. + explicit. Pure assembly queries execute through `addressable_tree`; the + dependency graph retains its custom evaluator and 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 @@ -71,8 +76,14 @@ The contracts are real; the first execution is intentionally modest: - 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. +- there is no textual query language, async runtime, persistent journal, or + production index; +- `addressable_tree` deliberately covers rooted canonical-address trees only; + other relationship views keep specialized evaluators; +- tree hosts yield projected nodes lazily, can index referent occurrences, and + use ordered sets for cycle detection and deduplication; +- runtime extraction preserves the revision needed by `resume`, while + validated in-place commits advance the clock without cloning a whole host. These are replaceable host choices, not placeholders in the core semantic types. No production or development dependencies were added. @@ -88,10 +99,10 @@ 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 check -p addressable -p addressable_tree --locked --target x86_64-unknown-none +cargo check -p addressable -p addressable_tree --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 +1.88 check -p addressable -p addressable_tree --locked --target x86_64-unknown-none cargo run -p addressable_tour --locked ``` @@ -108,9 +119,9 @@ forest-rs Apache-2.0 and MIT license texts matching its workspace metadata. ## 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. +The Exedra consumer exposed a genuine shared host seam, and the Basilica +assembly view now proves it against a second storage model. An outline/tree UI +is a credible next consumer: occurrence-aware rows, stable addresses, ordering, +budgets, and multiple views already fit. Pull live row deltas, lazy ranges, or +other execution machinery forward only when that consumer supplies concrete +behavior and deletion. diff --git a/crates/addressable/src/lib.rs b/crates/addressable/src/lib.rs index 1948585..a7158be 100644 --- a/crates/addressable/src/lib.rs +++ b/crates/addressable/src/lib.rs @@ -69,7 +69,7 @@ pub use live::{ DeltaError, LiveQueryId, QueryChange, QueryDelta, QuerySnapshot, ResultEntry, ResultIdentity, }; pub use query::{ - Cardinality, CardinalityKind, CyclePolicy, Deduplication, Many, One, Optional, Query, + Cardinality, CardinalityKind, CyclePolicy, Deduplication, Many, Measured, One, Optional, Query, QueryError, QueryResults, QuerySemantics, QueryStats, QueryStep, ResultOrdering, TraversalBudget, VisitIdentity, }; diff --git a/crates/addressable/src/live.rs b/crates/addressable/src/live.rs index d0ea707..3e9d336 100644 --- a/crates/addressable/src/live.rs +++ b/crates/addressable/src/live.rs @@ -281,15 +281,6 @@ where } 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(); - } } } @@ -337,15 +328,6 @@ pub enum QueryChange { /// 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. diff --git a/crates/addressable/src/query.rs b/crates/addressable/src/query.rs index d16ae9f..5dff335 100644 --- a/crates/addressable/src/query.rs +++ b/crates/addressable/src/query.rs @@ -6,7 +6,7 @@ use alloc::{boxed::Box, vec::Vec}; use core::marker::PhantomData; -use crate::BudgetExceeded; +use crate::{BudgetDimension, BudgetExceeded}; /// Marker for a query that must return exactly one result. /// @@ -402,6 +402,93 @@ pub struct QueryStats { pub max_depth_reached: u32, } +impl QueryStats { + /// Charges node visits, host work, and traversal depth against a budget. + /// + /// Query evaluators call this as work becomes observable. The first + /// exceeded dimension is returned with its limit and observed value. + pub fn charge( + &mut self, + budget: TraversalBudget, + nodes: u32, + work: u32, + depth: u32, + ) -> Result<(), QueryError> { + self.visited_nodes = self.visited_nodes.saturating_add(nodes); + self.work_units = self.work_units.saturating_add(work); + self.max_depth_reached = self.max_depth_reached.max(depth); + if depth > budget.max_depth { + return Err(QueryError::BudgetExceeded(BudgetExceeded::new( + BudgetDimension::Depth, + budget.max_depth, + depth, + ))); + } + if self.visited_nodes > budget.max_nodes { + return Err(QueryError::BudgetExceeded(BudgetExceeded::new( + BudgetDimension::Nodes, + budget.max_nodes, + self.visited_nodes, + ))); + } + if self.work_units > budget.max_work { + return Err(QueryError::BudgetExceeded(BudgetExceeded::new( + BudgetDimension::Work, + budget.max_work, + self.work_units, + ))); + } + Ok(()) + } + + /// Charges only host-defined work at the current maximum depth. + pub fn charge_work(&mut self, budget: TraversalBudget, work: u32) -> Result<(), QueryError> { + self.charge(budget, 0, work, self.max_depth_reached) + } +} + +/// A cardinality-shaped result paired with measured query work. +/// +/// Hosts produce this from [`QueryResults::require_one`] or +/// [`QueryResults::require_optional`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Measured { + value: T, + stats: QueryStats, +} + +impl Measured { + /// Pairs a cardinality-shaped result with its 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 result. + #[must_use] + pub fn into_parts(self) -> (T, QueryStats) { + (self.value, self.stats) + } + + /// Consumes the measurement and returns only its value. + #[must_use] + pub fn into_value(self) -> T { + self.value + } +} + /// Query items paired with measured execution work. /// /// A host returns this from many-result query execution. Callers inspect @@ -439,6 +526,35 @@ impl QueryResults { pub fn into_parts(self) -> (Box<[T]>, QueryStats) { (self.items, self.stats) } + + /// Requires exactly one item while preserving measured work. + pub fn require_one(self) -> Result, QueryError> { + let actual = self.items.len(); + if actual != 1 { + return Err(QueryError::Cardinality { + expected: CardinalityKind::One, + actual, + }); + } + let item = self + .items + .into_vec() + .pop() + .expect("cardinality was checked as exactly one"); + Ok(Measured::new(item, self.stats)) + } + + /// Allows zero or one item while preserving measured work. + pub fn require_optional(self) -> Result>, QueryError> { + let actual = self.items.len(); + if actual > 1 { + return Err(QueryError::Cardinality { + expected: CardinalityKind::Optional, + actual, + }); + } + Ok(Measured::new(self.items.into_vec().pop(), self.stats)) + } } #[cfg(test)] diff --git a/crates/addressable/src/resolution.rs b/crates/addressable/src/resolution.rs index a98f02b..ecf5b98 100644 --- a/crates/addressable/src/resolution.rs +++ b/crates/addressable/src/resolution.rs @@ -15,8 +15,7 @@ use crate::Revision; /// [`Self::resolved`] is for workflows where every exceptional outcome can be /// collapsed to absence. #[derive(Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum Resolution { +pub enum Resolution { /// The locator resolved without violating its policy. Resolved(T), /// Nothing currently occupies the requested location. @@ -58,12 +57,12 @@ pub enum Resolution { reason: PartialReason, }, /// The host does not expose a required capability in this view. - CapabilityUnavailable(String), + CapabilityUnavailable(C), /// A declared traversal budget was exhausted. BudgetExceeded(BudgetExceeded), } -impl Resolution { +impl Resolution { /// Returns the ordinary resolved value, if and only if no exceptional /// resolution state occurred. #[must_use] diff --git a/crates/addressable_reference/Cargo.toml b/crates/addressable_reference/Cargo.toml index e863cf1..614e420 100644 --- a/crates/addressable_reference/Cargo.toml +++ b/crates/addressable_reference/Cargo.toml @@ -12,6 +12,7 @@ publish = false [dependencies] addressable.workspace = true +addressable_tree.workspace = true [lints] workspace = true diff --git a/crates/addressable_reference/src/space.rs b/crates/addressable_reference/src/space.rs index f63e1ca..436920f 100644 --- a/crates/addressable_reference/src/space.rs +++ b/crates/addressable_reference/src/space.rs @@ -3,14 +3,17 @@ //! Basilica construction, resolution, query execution, and typed reads. -use std::{collections::VecDeque, vec::Vec}; +use std::collections::{BTreeSet, VecDeque}; +use std::vec::Vec; +pub use addressable::Measured; use addressable::{ - AbsoluteAddress, BudgetDimension, BudgetExceeded, Cardinality, CardinalityKind, CyclePolicy, - Deduplication, Endpoint, Explained, Locator, Many, One, Opinion, Optional, Pinned, QueryError, + AbsoluteAddress, BudgetDimension, BudgetExceeded, Cardinality, CyclePolicy, Deduplication, + Endpoint, Explained, Locator, Many, One, Opinion, Optional, Pinned, Query, QueryError, QueryResults, QuerySemantics, QueryStats, QueryStep, Resolution, ResolvedHandle, ResultOrdering, Revision, SpaceId, TraversalBudget, VisitIdentity, }; +use addressable_tree::{HostNode, TreeAxis, TreeHost, TreeNode, TreeRuntime}; use crate::model::{ BasilicaAxis, BasilicaLocation, BasilicaLocator, BasilicaPredicate, BasilicaQuery, @@ -18,44 +21,6 @@ use crate::model::{ FeatureKind, Load, LoadProvenance, LoadReason, Occurrence, OccurrenceId, SlotHandle, }; -/// Measured single-value or optional query output. -/// -/// [`Basilica::query_one`] and [`Basilica::query_optional`] produce this shape -/// so their cardinality-specific value does not lose the common query work -/// measurements. Use [`Self::value`] for the result and [`Self::stats`] for -/// diagnostics or budget tuning. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Measured { - value: T, - stats: QueryStats, -} - -impl Measured { - /// Pairs a cardinality-shaped value with measured query work for a host. - #[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. /// /// Start with [`Self::root_locator`], then resolve, query, watch, or form typed @@ -181,6 +146,9 @@ impl Basilica { /// ``` #[must_use] pub fn resolve(&self, locator: &BasilicaLocator) -> BasilicaResolution { + if *locator.view() == BasilicaView::Assembly { + return self.assembly_runtime().resolve(locator); + } if locator.space() != self.id { return Resolution::UnsupportedLocator; } @@ -225,6 +193,9 @@ impl Basilica { pinned: &Pinned, ) -> BasilicaResolution { let locator = pinned.locator(); + if *locator.view() == BasilicaView::Assembly { + return self.assembly_runtime().resolve_pinned(pinned); + } if locator.space() != self.id { return Resolution::UnsupportedLocator; } @@ -278,7 +249,11 @@ impl Basilica { &self, query: &BasilicaQuery, ) -> Result, QueryError> { - self.execute(query) + if let Some(query) = self.assembly_query(query) { + self.assembly_runtime().query_many(&query) + } else { + self.execute_custom(query) + } } /// Executes a query that requires exactly one result. @@ -286,19 +261,10 @@ impl Basilica { &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(), - }); + if let Some(query) = self.assembly_query(query) { + return self.assembly_runtime().query_one(&query); } - let item = items - .into_vec() - .pop() - .expect("cardinality was checked as exactly one"); - Ok(Measured::new(item, stats)) + self.execute_custom(query)?.require_one() } /// Executes a query that allows zero or one result. @@ -306,15 +272,10 @@ impl Basilica { &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(), - }); + if let Some(query) = self.assembly_query(query) { + return self.assembly_runtime().query_optional(&query); } - Ok(Measured::new(items.into_vec().pop(), stats)) + self.execute_custom(query)?.require_optional() } /// Resolves a revision-scoped runtime feature slot. @@ -412,6 +373,21 @@ impl Basilica { .find(|occurrence| occurrence.id == id) } + fn projected_node(&self, occurrence: &Occurrence) -> HostNode { + let slot = self + .features + .iter() + .position(|feature| feature.id == occurrence.referent) + .and_then(|index| u32::try_from(index).ok()) + .map(SlotHandle::new); + TreeNode::new( + occurrence.referent, + occurrence.id, + occurrence.address.clone(), + slot, + ) + } + pub(crate) fn location(&self, occurrence: &Occurrence) -> BasilicaLocation { BasilicaLocation::new( occurrence.view, @@ -441,7 +417,42 @@ impl Basilica { Ok(()) } - fn execute( + fn assembly_runtime(&self) -> TreeRuntime<&Self> { + TreeRuntime::resume(self.revision, self) + } + + fn assembly_query( + &self, + query: &BasilicaQuery, + ) -> Option> { + if *query.start().view() != BasilicaView::Assembly { + return None; + } + let mut mapped = Query::::many( + query.start().clone(), + ) + .with_cardinality::(); + for step in query.steps() { + mapped = match step { + QueryStep::Traverse(BasilicaAxis::Children) => mapped.traverse(TreeAxis::Children), + QueryStep::Traverse(BasilicaAxis::Descendants) => { + mapped.traverse(TreeAxis::Descendants) + } + QueryStep::Traverse(_) => return None, + QueryStep::Filter(predicate) => mapped.filter(predicate.clone()), + }; + } + let semantics = query.semantics(); + Some( + mapped + .deduplicate(semantics.deduplication) + .order(semantics.ordering) + .cycles(semantics.cycle_policy) + .budget(semantics.budget), + ) + } + + fn execute_custom( &self, query: &BasilicaQuery, ) -> Result, QueryError> { @@ -450,7 +461,7 @@ impl Basilica { }; let semantics = query.semantics(); let mut stats = QueryStats::default(); - charge(&mut stats, semantics.budget, 1, 1, 0)?; + stats.charge(semantics.budget, 1, 1, 0)?; let mut frontier = vec![start]; for step in query.steps() { @@ -464,7 +475,7 @@ impl Basilica { .into_iter() .filter(|location| self.matches(location, predicate)) .collect(); - charge_work(&mut stats, semantics.budget, inspected)?; + stats.charge_work(semantics.budget, inspected)?; filtered } }; @@ -538,7 +549,7 @@ impl Basilica { for occurrence in self.occurrences.iter().filter(|occurrence| { occurrence.view == view && occurrence.referent == *location.referent() }) { - charge(stats, semantics.budget, 1, 1, 1)?; + stats.charge(semantics.budget, 1, 1, 1)?; output.push(self.location(occurrence)); } } @@ -569,7 +580,7 @@ impl Basilica { }) { let target = if reverse { edge.from } else { edge.to }; let occurrence = self.occurrence(target).ok_or(QueryError::UnsupportedStep)?; - charge(stats, budget, 1, 1, 1)?; + stats.charge(budget, 1, 1, 1)?; output.push(self.location(occurrence)); } Ok(()) @@ -587,8 +598,8 @@ impl Basilica { 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()]; + let mut visited_occurrences = BTreeSet::from([*start.occurrence()]); + let mut visited_referents = BTreeSet::from([*start.referent()]); while let Some((current, depth)) = queue.pop_front() { for edge in self @@ -608,12 +619,12 @@ impl Basilica { ))); } let revisited = match semantics.cycle_policy { - CyclePolicy::Error => visited_occurrences.contains(&occurrence.id), + CyclePolicy::Error => !visited_occurrences.insert(occurrence.id), CyclePolicy::SkipVisited(VisitIdentity::Occurrence) => { - visited_occurrences.contains(&occurrence.id) + !visited_occurrences.insert(occurrence.id) } CyclePolicy::SkipVisited(VisitIdentity::Referent) => { - visited_referents.contains(&occurrence.referent) + !visited_referents.insert(occurrence.referent) } }; if revisited { @@ -622,9 +633,9 @@ impl Basilica { } continue; } - visited_occurrences.push(occurrence.id); - visited_referents.push(occurrence.referent); - charge(stats, semantics.budget, 1, 1, next_depth)?; + visited_occurrences.insert(occurrence.id); + visited_referents.insert(occurrence.referent); + stats.charge(semantics.budget, 1, 1, next_depth)?; output.push(self.location(occurrence)); queue.push_back((occurrence.id, next_depth)); } @@ -645,6 +656,82 @@ impl Basilica { } } +impl TreeHost for Basilica { + type Space = BasilicaSpace; + type View = BasilicaView; + type Referent = FeatureId; + type Occurrence = OccurrenceId; + type Handle = SlotHandle; + type Predicate = BasilicaPredicate; + + fn supports_view(&self, view: &Self::View) -> bool { + *view == BasilicaView::Assembly + } + + fn node_at( + &self, + view: &Self::View, + address: &AbsoluteAddress, + ) -> Option> { + self.occurrences + .iter() + .find(|occurrence| occurrence.view == *view && occurrence.address == *address) + .map(|occurrence| self.projected_node(occurrence)) + } + + fn nodes<'a>(&'a self, view: &'a Self::View) -> impl Iterator> + 'a { + self.occurrences + .iter() + .filter(move |occurrence| occurrence.view == *view) + .map(|occurrence| self.projected_node(occurrence)) + } + + fn occurrences_of<'a>( + &'a self, + view: &'a Self::View, + referent: &'a Self::Referent, + ) -> impl Iterator> + 'a { + self.occurrences + .iter() + .filter(move |occurrence| occurrence.view == *view && occurrence.referent == *referent) + .map(|occurrence| self.projected_node(occurrence)) + } + + fn children<'a>( + &'a self, + view: &'a Self::View, + occurrence: &'a Self::Occurrence, + ) -> impl Iterator> + 'a { + self.edges + .iter() + .filter(move |edge| edge.kind == EdgeKind::Assembly && edge.from == *occurrence) + .filter_map(|edge| self.occurrence(edge.to)) + .filter(move |child| child.view == *view) + .map(|child| self.projected_node(child)) + } + + fn parent(&self, view: &Self::View, occurrence: &Self::Occurrence) -> Option> { + let edge = self + .edges + .iter() + .find(|edge| edge.kind == EdgeKind::Assembly && edge.to == *occurrence)?; + let parent = self.occurrence(edge.from)?; + (parent.view == *view).then(|| self.projected_node(parent)) + } + + fn matches(&self, node: &HostNode, predicate: &Self::Predicate) -> bool { + let Some(feature) = self.feature(*node.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 validate a resolved location for an endpoint read or handle lookup. /// /// Returned by [`Basilica::read_load`] and [`Basilica::resolved_handle`], and @@ -708,72 +795,16 @@ const fn edge(id: u64, from: u64, to: u64, kind: EdgeKind) -> Edge { } } -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 - } - }); + let mut seen = BTreeSet::new(); + frontier.retain(|location| seen.insert(*location.occurrence())); } Deduplication::Referent => { - let mut seen = Vec::new(); - frontier.retain(|location| { - if seen.contains(location.referent()) { - false - } else { - seen.push(*location.referent()); - true - } - }); + let mut seen = BTreeSet::new(); + frontier.retain(|location| seen.insert(*location.referent())); } } } diff --git a/crates/addressable_tree/Cargo.toml b/crates/addressable_tree/Cargo.toml new file mode 100644 index 0000000..64fc260 --- /dev/null +++ b/crates/addressable_tree/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "addressable_tree" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Reusable no_std Addressable execution for host-owned rooted trees" +readme = "README.md" +keywords = ["addressing", "tree", "query", "no_std"] +categories = ["data-structures", "no-std"] +publish = false + +[lints] +workspace = true + +[dependencies] +addressable.workspace = true diff --git a/crates/addressable_tree/README.md b/crates/addressable_tree/README.md new file mode 100644 index 0000000..c7bc390 --- /dev/null +++ b/crates/addressable_tree/README.md @@ -0,0 +1,14 @@ +# addressable_tree + +`addressable_tree` executes Addressable locators and queries over a host-owned +rooted tree or forest. Hosts keep their storage, indexes, node values, domain +predicates, and runtime handles; the reusable runtime owns revision context, +rich resolution, tree traversal, cardinality, ordering, deduplication, cycle +policy, and budgets. + +Implement `TreeHost` for the domain storage type, construct `TreeRuntime` with a +host-assigned `SpaceId`, and use the runtime's `resolve`, `resolve_pinned`, +`query_many`, `query_one`, `query_optional`, and `resolved_handle` methods. + +The crate is always `no_std + alloc`, owns no storage engine, and depends only +on `addressable`. diff --git a/crates/addressable_tree/src/lib.rs b/crates/addressable_tree/src/lib.rs new file mode 100644 index 0000000..ff4ed79 --- /dev/null +++ b/crates/addressable_tree/src/lib.rs @@ -0,0 +1,1060 @@ +// Copyright 2026 the Addressable Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Reusable Addressable execution over host-owned rooted trees. +//! +//! This crate owns revisioned locator and tree-query execution. It explicitly +//! does not own node storage, indexes, domain predicates, values, or mutation +//! policy. A host implements [`TreeHost`], then binds one host value to a +//! runtime [`SpaceId`] through [`TreeRuntime::new`]. +//! +//! ``` +//! use addressable::{AbsoluteAddress, Query, Resolution, SpaceId}; +//! use addressable_tree::{TreeAxis, TreeHost, TreeNode, TreeRuntime}; +//! +//! #[derive(Clone, Copy, Debug, PartialEq, Eq)] +//! enum View { Instances } +//! #[derive(Clone, Copy, Debug, PartialEq, Eq)] +//! enum Predicate { Any } +//! #[derive(Clone)] +//! enum Space {} +//! +//! #[derive(Clone, Debug)] +//! struct Host; +//! +//! impl TreeHost for Host { +//! type Space = Space; +//! type View = View; +//! type Referent = u64; +//! type Occurrence = u64; +//! type Handle = u32; +//! type Predicate = Predicate; +//! +//! fn supports_view(&self, view: &View) -> bool { +//! *view == View::Instances +//! } +//! +//! fn node_at( +//! &self, +//! _view: &View, +//! address: &AbsoluteAddress, +//! ) -> Option> { +//! (address.depth() == 0).then(|| { +//! TreeNode::new(1, 1, AbsoluteAddress::root(), Some(0)) +//! }) +//! } +//! +//! fn nodes(&self, view: &View) -> impl Iterator> { +//! self.node_at(view, &AbsoluteAddress::root()).into_iter() +//! } +//! +//! fn children( +//! &self, +//! _view: &View, +//! _occurrence: &u64, +//! ) -> impl Iterator> { +//! core::iter::empty() +//! } +//! +//! fn parent(&self, _view: &View, _occurrence: &u64) -> Option> { +//! None +//! } +//! +//! fn matches(&self, _node: &TreeNode, predicate: &Predicate) -> bool { +//! *predicate == Predicate::Any +//! } +//! } +//! +//! let runtime = TreeRuntime::new(SpaceId::::new(7), Host); +//! let root = runtime.root_locator(View::Instances); +//! assert!(matches!(runtime.resolve(&root), Resolution::Resolved(_))); +//! +//! let results = runtime +//! .query_many(&Query::many(root).traverse(TreeAxis::Children))?; +//! assert!(results.items().is_empty()); +//! # Ok::<(), addressable::QueryError>(()) +//! ``` + +#![no_std] + +extern crate alloc; +#[cfg(test)] +extern crate std; + +use alloc::{collections::BTreeSet, collections::VecDeque, vec, vec::Vec}; +use core::fmt; + +pub use addressable::Measured; +use addressable::{ + AbsoluteAddress, BudgetDimension, BudgetExceeded, Cardinality, CyclePolicy, Deduplication, + Location, Locator, Many, One, Optional, Pinned, Query, QueryError, QueryResults, + QuerySemantics, QueryStats, QueryStep, Resolution, ResolvedHandle, ResultOrdering, Revision, + SpaceId, VisitIdentity, +}; + +/// One host-projected tree node with durable identities and optional runtime handle. +/// +/// Hosts construct these in [`TreeHost`] methods. [`TreeRuntime`] turns them +/// into revision-scoped [`Location`] values and [`ResolvedHandle`] values. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TreeNode { + referent: R, + occurrence: O, + address: AbsoluteAddress, + handle: Option, +} + +impl TreeNode { + /// Projects one current node from host storage. + #[must_use] + pub const fn new( + referent: R, + occurrence: O, + address: AbsoluteAddress, + handle: Option, + ) -> Self { + Self { + referent, + occurrence, + address, + handle, + } + } + + /// Returns durable semantic referent identity. + #[must_use] + pub const fn referent(&self) -> &R { + &self.referent + } + + /// Returns durable contextual occurrence identity. + #[must_use] + pub const fn occurrence(&self) -> &O { + &self.occurrence + } + + /// Returns the canonical exact address in the projected view. + #[must_use] + pub const fn address(&self) -> &AbsoluteAddress { + &self.address + } + + /// Returns a host-local runtime accelerator when this node has one. + #[must_use] + pub const fn handle(&self) -> Option<&H> { + self.handle.as_ref() + } +} + +/// Host-owned projection required by [`TreeRuntime`]. +/// +/// The host retains storage and indexing choices. Every method observes one +/// coherent host value. `node_at` must return at most one node for an exact +/// address; `nodes` must enumerate every node in the view; `children` and +/// `parent` must describe the same rooted forest. Iterators yield nodes in +/// deterministic traversal order. +pub trait TreeHost: Sized { + /// Type-level address-space marker. + type Space: Clone; + /// Named domain view. + type View: Clone + Eq; + /// Durable semantic referent identity. + type Referent: Clone + Ord; + /// Durable contextual occurrence identity. + type Occurrence: Clone + Ord; + /// Runtime-local node accelerator. + type Handle: Clone; + /// Domain-owned node predicate. + type Predicate; + + /// Reports whether this host projects the named rooted-tree view. + fn supports_view(&self, view: &Self::View) -> bool; + + /// Projects the node at one canonical exact address. + fn node_at( + &self, + view: &Self::View, + address: &AbsoluteAddress, + ) -> Option>; + + /// Iterates every node in one view in deterministic traversal order. + fn nodes<'a>(&'a self, view: &'a Self::View) -> impl Iterator> + 'a; + + /// Iterates current occurrences of one durable referent. + /// + /// The default scans [`Self::nodes`] lazily. Indexed hosts should override + /// this so pinned movement detection does not inspect unrelated nodes. + fn occurrences_of<'a>( + &'a self, + view: &'a Self::View, + referent: &'a Self::Referent, + ) -> impl Iterator> + 'a { + self.nodes(view) + .filter(move |node| node.referent() == referent) + } + + /// Iterates the direct children of one occurrence in deterministic order. + fn children<'a>( + &'a self, + view: &'a Self::View, + occurrence: &'a Self::Occurrence, + ) -> impl Iterator> + 'a; + + /// Projects the parent of one occurrence, if it has one. + fn parent(&self, view: &Self::View, occurrence: &Self::Occurrence) -> Option>; + + /// Evaluates one domain predicate against a projected node. + fn matches(&self, node: &HostNode, predicate: &Self::Predicate) -> bool; +} + +impl TreeHost for &H { + type Space = H::Space; + type View = H::View; + type Referent = H::Referent; + type Occurrence = H::Occurrence; + type Handle = H::Handle; + type Predicate = H::Predicate; + + fn supports_view(&self, view: &Self::View) -> bool { + H::supports_view(self, view) + } + + fn node_at( + &self, + view: &Self::View, + address: &AbsoluteAddress, + ) -> Option> { + H::node_at(self, view, address) + } + + fn nodes<'a>(&'a self, view: &'a Self::View) -> impl Iterator> + 'a { + H::nodes(self, view) + } + + fn occurrences_of<'a>( + &'a self, + view: &'a Self::View, + referent: &'a Self::Referent, + ) -> impl Iterator> + 'a { + H::occurrences_of(self, view, referent) + } + + fn children<'a>( + &'a self, + view: &'a Self::View, + occurrence: &'a Self::Occurrence, + ) -> impl Iterator> + 'a { + H::children(self, view, occurrence) + } + + fn parent(&self, view: &Self::View, occurrence: &Self::Occurrence) -> Option> { + H::parent(self, view, occurrence) + } + + fn matches(&self, node: &HostNode, predicate: &Self::Predicate) -> bool { + H::matches(self, node, predicate) + } +} + +/// Shared rooted-tree navigation axes. +/// +/// Append these through [`Query::traverse`], then execute the resulting +/// [`TreeQuery`] through the matching method on [`TreeRuntime`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum TreeAxis { + /// Direct children of every current occurrence. + Children, + /// Recursive descendants of every current occurrence. + Descendants, + /// Direct parent of every current occurrence. + Parent, +} + +/// Resolved location produced by a tree runtime over host `H`. +pub type TreeLocation = Location< + ::Space, + ::View, + ::Referent, + ::Occurrence, +>; + +/// One node projected by tree host `H`. +pub type HostNode = TreeNode< + ::Space, + ::Referent, + ::Occurrence, + ::Handle, +>; + +/// Revision-scoped runtime handle produced for tree host `H`. +pub type TreeHandle = ResolvedHandle<::Space, ::Handle>; + +/// Locator accepted by a tree runtime over host `H`. +pub type TreeLocator = Locator<::Space, ::View>; + +/// Rich resolution result produced by a tree runtime over host `H`. +pub type TreeResolution = Resolution< + ::Space, + TreeLocation, + ::Referent, + AbsoluteAddress<::Space>, +>; + +/// Typed query executed by a tree runtime over host `H`. +pub type TreeQuery = Query, TreeAxis, ::Predicate, C>; + +/// Revisioned Addressable execution over one host-owned tree value. +/// +/// Construct this with [`Self::new`]. It exposes immutable host access so no +/// mutation can bypass its revision. Domain code validates an operation, then +/// applies it through [`Self::commit`]. [`Self::into_host`] and [`Self::resume`] +/// preserve the clock when ownership must cross a boundary. +#[derive(Clone, Debug)] +pub struct TreeRuntime { + id: SpaceId, + revision: Revision, + host: H, +} + +struct AdvanceRevision<'a, S> { + revision: &'a mut Revision, +} + +impl Drop for AdvanceRevision<'_, S> { + fn drop(&mut self) { + *self.revision = self.revision.next(); + } +} + +impl TreeRuntime { + /// Binds a host value to one runtime address-space identity. + /// + /// `id` must identify a genuinely new space instance. Restore an extracted + /// instance with [`Self::resume`]; reusing its id with `new` would restart + /// the clock and could make stale observations appear current. + #[must_use] + pub fn new(id: SpaceId, host: H) -> Self { + Self { + id, + revision: Revision::initial(id), + host, + } + } + + /// Restores a host with its previously recorded runtime revision. + /// + /// Use the values returned by [`Self::into_host`]. The revision already + /// carries the owning [`SpaceId`], so a mismatched pair cannot be supplied. + #[must_use] + pub const fn resume(revision: Revision, host: H) -> Self { + Self { + id: revision.space(), + revision, + host, + } + } + + /// Returns this runtime address-space identity. + #[must_use] + pub const fn id(&self) -> SpaceId { + self.id + } + + /// Returns the revision governing every current result. + #[must_use] + pub const fn revision(&self) -> Revision { + self.revision + } + + /// Borrows the host-owned tree value immutably. + #[must_use] + pub const fn host(&self) -> &H { + &self.host + } + + /// Recovers the revision and host value, consuming the live runtime. + /// + /// Pass both values to [`Self::resume`] to restore the same clock. Mutation + /// should normally remain inside the runtime through [`Self::commit`]. + #[must_use] + pub fn into_host(self) -> (Revision, H) { + (self.revision, self.host) + } + + /// Applies one already-validated host mutation and advances once. + /// + /// The closure is infallible by design: domain code validates fallible + /// preconditions before entering the commit. The returned revision governs + /// the closure's result and every subsequent runtime observation. The + /// revision also advances if the closure unwinds after partially mutating + /// the host, so a caught panic cannot leave changed data at the old clock. + pub fn commit(&mut self, mutation: impl FnOnce(&mut H) -> T) -> (Revision, T) { + let value = { + let _advance = AdvanceRevision { + revision: &mut self.revision, + }; + mutation(&mut self.host) + }; + (self.revision, value) + } + + /// Replaces the complete host value and advances the revision once. + /// + /// Domain transaction code calls this only after validating every + /// precondition and successfully applying every operation to a private + /// replacement value. Dry runs and no-op batches retain the current host. + pub fn replace_host(&mut self, host: H) -> Revision { + self.host = host; + self.revision = self.revision.next(); + self.revision + } + + /// Returns an exact locator for `/` in one supported tree view. + #[must_use] + pub fn root_locator(&self, view: H::View) -> TreeLocator { + Locator::exact(self.id, view, AbsoluteAddress::root()) + } + + /// Resolves an exact or relative locator through the host projection. + #[must_use] + pub fn resolve(&self, locator: &TreeLocator) -> TreeResolution { + if locator.space() != self.id || !self.host.supports_view(locator.view()) { + return Resolution::UnsupportedLocator; + } + let Ok(address) = locator.to_absolute() else { + return Resolution::UnsupportedLocator; + }; + self.host + .node_at(locator.view(), &address) + .map(|node| self.location(locator.view().clone(), node)) + .map_or(Resolution::Absent, Resolution::Resolved) + } + + /// Resolves a pin without silently accepting staleness or rebinding. + #[must_use] + pub fn resolve_pinned( + &self, + pinned: &Pinned, + ) -> TreeResolution { + let locator = pinned.locator(); + if locator.space() != self.id || !self.host.supports_view(locator.view()) { + return Resolution::UnsupportedLocator; + } + let Ok(address) = locator.to_absolute() else { + return Resolution::UnsupportedLocator; + }; + + if let Some(node) = self.host.node_at(locator.view(), &address) { + let location = self.location(locator.view().clone(), node); + if location.referent() != pinned.expected_referent() { + return Resolution::Rebound { + expected: pinned.expected_referent().clone(), + actual: location.referent().clone(), + 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 + .host + .occurrences_of(locator.view(), pinned.expected_referent()) + .map(|node| self.location(locator.view().clone(), node)) + .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 tree query. + pub fn query_many( + &self, + query: &TreeQuery, + ) -> Result>, QueryError> { + self.execute(query) + } + + /// Executes a tree query requiring exactly one result. + pub fn query_one( + &self, + query: &TreeQuery, + ) -> Result>, QueryError> { + self.execute(query)?.require_one() + } + + /// Executes a tree query allowing zero or one result. + pub fn query_optional( + &self, + query: &TreeQuery, + ) -> Result>>, QueryError> { + self.execute(query)?.require_optional() + } + + /// Resolves a location to its revision-scoped host-local handle. + pub fn resolved_handle( + &self, + location: &TreeLocation, + ) -> Result, TreeReadError> { + let node = self.validate_location(location)?; + let handle = node.handle().cloned().ok_or(TreeReadError::NoHandle)?; + Ok(ResolvedHandle::new(self.revision, handle)) + } + + /// Validates a location and recovers its current host projection. + /// + /// Domain endpoint reads use this before consuming node identity or a + /// runtime handle. + pub fn validate_location( + &self, + location: &TreeLocation, + ) -> Result, TreeReadError> { + if location.space() != self.id { + return Err(TreeReadError::WrongSpace); + } + if !self.host.supports_view(location.view()) { + return Err(TreeReadError::WrongView); + } + if location.revision() != self.revision { + return Err(TreeReadError::StaleRevision { + expected: location.revision(), + actual: self.revision, + }); + } + let node = self + .host + .node_at(location.view(), location.address()) + .ok_or(TreeReadError::MissingOccurrence)?; + if node.occurrence() != location.occurrence() { + return Err(TreeReadError::Moved); + } + if node.referent() != location.referent() { + return Err(TreeReadError::Rebound); + } + Ok(node) + } + + fn location(&self, view: H::View, node: HostNode) -> TreeLocation { + Location::new( + view, + self.revision, + node.referent, + node.occurrence, + node.address, + ) + } + + fn execute( + &self, + query: &TreeQuery, + ) -> Result>, QueryError> { + let locator = query.start(); + if locator.space() != self.id || !self.host.supports_view(locator.view()) { + return Err(QueryError::StartDidNotResolve); + } + let address = locator + .to_absolute() + .map_err(|_| QueryError::StartDidNotResolve)?; + let start = self + .host + .node_at(locator.view(), &address) + .ok_or(QueryError::StartDidNotResolve)?; + let semantics = query.semantics(); + let mut stats = QueryStats::default(); + stats.charge(semantics.budget, 1, 1, 0)?; + let mut frontier = vec![start]; + + for step in query.steps() { + frontier = match step { + QueryStep::Traverse(axis) => { + self.traverse(locator.view(), &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(|node| self.host.matches(node, predicate)) + .collect(); + stats.charge_work(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 + .into_iter() + .map(|node| self.location(locator.view().clone(), node)), + stats, + )) + } + + fn traverse( + &self, + view: &H::View, + frontier: &[HostNode], + axis: TreeAxis, + semantics: QuerySemantics, + stats: &mut QueryStats, + ) -> Result>, QueryError> { + let mut output = Vec::new(); + for node in frontier { + match axis { + TreeAxis::Children => { + for child in self.host.children(view, node.occurrence()) { + stats.charge(semantics.budget, 1, 1, 1)?; + output.push(child); + } + } + TreeAxis::Descendants => { + self.push_descendants(view, node, semantics, stats, &mut output)?; + } + TreeAxis::Parent => { + if let Some(parent) = self.host.parent(view, node.occurrence()) { + stats.charge(semantics.budget, 1, 1, 1)?; + output.push(parent); + } + } + } + } + Ok(output) + } + + fn push_descendants( + &self, + view: &H::View, + start: &HostNode, + semantics: QuerySemantics, + stats: &mut QueryStats, + output: &mut Vec>, + ) -> Result<(), QueryError> { + let mut queue = VecDeque::from_iter( + self.host + .children(view, start.occurrence()) + .map(|node| (node, 1_u32)), + ); + let mut visited_occurrences = BTreeSet::from([start.occurrence().clone()]); + let mut visited_referents = BTreeSet::from([start.referent().clone()]); + + while let Some((node, depth)) = queue.pop_front() { + let revisited = match semantics.cycle_policy { + CyclePolicy::Error | CyclePolicy::SkipVisited(VisitIdentity::Occurrence) => { + !visited_occurrences.insert(node.occurrence().clone()) + } + CyclePolicy::SkipVisited(VisitIdentity::Referent) => { + !visited_referents.insert(node.referent().clone()) + } + }; + if revisited { + if semantics.cycle_policy == CyclePolicy::Error { + return Err(QueryError::Cycle); + } + continue; + } + visited_occurrences.insert(node.occurrence().clone()); + visited_referents.insert(node.referent().clone()); + stats.charge(semantics.budget, 1, 1, depth)?; + + let next_depth = depth.saturating_add(1); + queue.extend( + self.host + .children(view, node.occurrence()) + .map(|child| (child, next_depth)), + ); + output.push(node); + } + Ok(()) + } +} + +/// Failure to consume a tree runtime's location or handle projection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TreeReadError { + /// The location belongs to another runtime space instance. + WrongSpace, + /// The location names a view this host does not project as a tree. + WrongView, + /// The location was resolved at an earlier runtime revision. + StaleRevision { + /// Revision carried by the stale value. + expected: Revision, + /// Current runtime revision. + actual: Revision, + }, + /// The exact address no longer has a node. + MissingOccurrence, + /// The occurrence now has another exact address. + Moved, + /// The exact address now denotes another referent. + Rebound, + /// The projected node has no runtime-local handle. + NoHandle, +} + +impl fmt::Display for TreeReadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::WrongSpace => "tree location belongs to another space", + Self::WrongView => "tree location uses an unsupported view", + Self::StaleRevision { .. } => "tree location is stale", + Self::MissingOccurrence => "tree occurrence is absent", + Self::Moved => "tree occurrence moved", + Self::Rebound => "tree address rebound to another referent", + Self::NoHandle => "tree node has no runtime handle", + }) + } +} + +impl core::error::Error for TreeReadError {} + +fn deduplicate(frontier: &mut Vec>, identity: Deduplication) { + match identity { + Deduplication::None => {} + Deduplication::Occurrence => { + let mut seen = BTreeSet::new(); + frontier.retain(|node| seen.insert(node.occurrence().clone())); + } + Deduplication::Referent => { + let mut seen = BTreeSet::new(); + frontier.retain(|node| seen.insert(node.referent().clone())); + } + } +} + +#[cfg(test)] +mod tests { + use alloc::{string::ToString, vec, vec::Vec}; + use core::cell::Cell; + use std::panic::{AssertUnwindSafe, catch_unwind}; + + use addressable::{ + AbsoluteAddress, Deduplication, Locator, Pinned, Query, QueryError, RelativeAddress, + Resolution, SpaceId, TraversalBudget, + }; + + use super::{TreeAxis, TreeHost, TreeNode, TreeReadError, TreeRuntime}; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Space {} + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum View { + Instances, + Unsupported, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Predicate { + Referent(u64), + } + + #[derive(Clone, Debug)] + struct StoredNode { + occurrence: u64, + referent: u64, + parent: Option, + address: AbsoluteAddress, + } + + #[derive(Clone, Debug)] + struct Host { + nodes: Vec, + node_lookups: Cell, + } + + impl Host { + fn new() -> Self { + Self { + nodes: vec![ + stored(0, 0, None, "/"), + stored(1, 1, Some(0), "/root"), + stored(2, 10, Some(1), "/root/a"), + stored(3, 10, Some(1), "/root/b"), + stored(4, 20, Some(2), "/root/a/leaf"), + ], + node_lookups: Cell::new(0), + } + } + + fn project(node: &StoredNode) -> TreeNode { + TreeNode::new( + node.referent, + node.occurrence, + node.address.clone(), + Some(node.occurrence), + ) + } + } + + impl TreeHost for Host { + type Space = Space; + type View = View; + type Referent = u64; + type Occurrence = u64; + type Handle = u64; + type Predicate = Predicate; + + fn supports_view(&self, view: &View) -> bool { + *view == View::Instances + } + + fn node_at( + &self, + _view: &View, + address: &AbsoluteAddress, + ) -> Option> { + self.node_lookups + .set(self.node_lookups.get().saturating_add(1)); + self.nodes + .iter() + .find(|node| node.address == *address) + .map(Self::project) + } + + fn nodes(&self, _view: &View) -> impl Iterator> { + self.nodes.iter().map(Self::project) + } + + fn children<'a>( + &'a self, + _view: &'a View, + occurrence: &'a u64, + ) -> impl Iterator> + 'a { + self.nodes + .iter() + .filter(|node| node.parent == Some(*occurrence)) + .map(Self::project) + } + + fn parent(&self, _view: &View, occurrence: &u64) -> Option> { + let parent = self + .nodes + .iter() + .find(|node| node.occurrence == *occurrence)? + .parent?; + self.nodes + .iter() + .find(|node| node.occurrence == parent) + .map(Self::project) + } + + fn matches(&self, node: &TreeNode, predicate: &Predicate) -> bool { + match predicate { + Predicate::Referent(referent) => node.referent() == referent, + } + } + } + + fn stored(occurrence: u64, referent: u64, parent: Option, address: &str) -> StoredNode { + StoredNode { + occurrence, + referent, + parent, + address: AbsoluteAddress::parse(address).expect("static test address is valid"), + } + } + + fn runtime() -> TreeRuntime { + TreeRuntime::new(SpaceId::new(7), Host::new()) + } + + #[test] + fn exact_relative_and_pinned_resolution_share_one_host_projection() { + let runtime = runtime(); + let exact = Locator::exact( + runtime.id(), + View::Instances, + AbsoluteAddress::parse("/root/a").expect("valid exact address"), + ); + let relative = Locator::relative( + runtime.id(), + View::Instances, + AbsoluteAddress::parse("/root").expect("valid base"), + RelativeAddress::parse("a").expect("valid relative address"), + ); + let Resolution::Resolved(exact_location) = runtime.resolve(&exact) else { + panic!("exact locator resolves"); + }; + let Resolution::Resolved(relative_location) = runtime.resolve(&relative) else { + panic!("relative locator resolves"); + }; + assert_eq!( + exact_location, relative_location, + "exact and relative recipes reach one location" + ); + + let pin = Pinned::new(exact, *exact_location.referent(), exact_location.revision()); + assert!( + matches!(runtime.resolve_pinned(&pin), Resolution::Resolved(_)), + "fresh pin resolves normally" + ); + assert!( + matches!( + runtime.resolve(&Locator::exact( + runtime.id(), + View::Unsupported, + AbsoluteAddress::root(), + )), + Resolution::UnsupportedLocator + ), + "unsupported view is explicit" + ); + } + + #[test] + fn queries_apply_tree_axes_cardinality_and_referent_deduplication() { + let runtime = runtime(); + let all = runtime + .query_many( + &Query::many(runtime.root_locator(View::Instances)).traverse(TreeAxis::Descendants), + ) + .expect("descendant query succeeds"); + assert_eq!(all.items().len(), 4, "root has four descendants"); + + runtime.host().node_lookups.set(0); + let shared = runtime + .query_many( + &Query::many(runtime.root_locator(View::Instances)) + .traverse(TreeAxis::Descendants) + .filter(Predicate::Referent(10)) + .deduplicate(Deduplication::Referent), + ) + .expect("referent query succeeds"); + assert_eq!( + shared.items().len(), + 1, + "referent deduplication collapses two occurrences" + ); + assert_eq!( + runtime.host().node_lookups.get(), + 1, + "filters reuse projected frontier nodes" + ); + + let one = runtime + .query_one( + &Query::one(runtime.root_locator(View::Instances)) + .traverse(TreeAxis::Descendants) + .filter(Predicate::Referent(20)), + ) + .expect("one leaf matches"); + assert_eq!( + one.value().address().to_string(), + "/root/a/leaf", + "one-result query retains exact context" + ); + } + + #[test] + fn budget_failure_and_host_replacement_are_revision_safe() { + let mut runtime = runtime(); + let query = Query::many(runtime.root_locator(View::Instances)) + .traverse(TreeAxis::Descendants) + .budget(TraversalBudget::new(1, 10, 10, 10)); + assert!( + matches!( + runtime.query_many(&query), + Err(QueryError::BudgetExceeded(_)) + ), + "depth limit stops recursive traversal" + ); + + let locator = Locator::exact( + runtime.id(), + View::Instances, + AbsoluteAddress::parse("/root/a").expect("valid address"), + ); + let Resolution::Resolved(location) = runtime.resolve(&locator) else { + panic!("handle target resolves"); + }; + let handle = runtime + .resolved_handle(&location) + .expect("projected node has a handle"); + let old_revision = runtime.revision(); + runtime.replace_host(Host::new()); + assert_eq!( + runtime.revision(), + old_revision.next(), + "whole-host commit advances exactly once" + ); + assert!( + matches!( + runtime.validate_location(&location), + Err(TreeReadError::StaleRevision { .. }) + ), + "old location is stale" + ); + assert_ne!( + handle.revision(), + runtime.revision(), + "old handle remains tied to its observation revision" + ); + } + + #[test] + fn commit_and_resume_preserve_the_revision_clock() { + let mut runtime = runtime(); + let locator = Locator::exact( + runtime.id(), + View::Instances, + AbsoluteAddress::parse("/root/a").expect("valid address"), + ); + let Resolution::Resolved(location) = runtime.resolve(&locator) else { + panic!("pin target resolves"); + }; + let pin = Pinned::new(locator, *location.referent(), location.revision()); + + runtime.commit(|host| host.nodes.reverse()); + assert!(matches!( + runtime.resolve_pinned(&pin), + Resolution::StaleRevision { .. } + )); + + let (revision, host) = runtime.into_host(); + let resumed = TreeRuntime::resume(revision, host); + assert_eq!(resumed.revision(), revision); + assert!(matches!( + resumed.resolve_pinned(&pin), + Resolution::StaleRevision { .. } + )); + } + + #[test] + fn caught_commit_panic_cannot_preserve_the_old_revision() { + let mut runtime = runtime(); + let revision = runtime.revision(); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + runtime.commit(|host| { + host.nodes.reverse(); + panic!("mutation failed after writing"); + }); + })); + + assert!(outcome.is_err()); + assert_eq!(runtime.revision(), revision.next()); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ad084f4..b1330ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -231,6 +231,8 @@ shape to evaluate locally is: - `addressable`: `no_std` plus `alloc` vocabulary, exact addressing, resolution contracts, typed query IR, and semantic result types; +- a dependency-light `no_std` runtime for reusable execution over host-owned + rooted trees when consumer evidence proves the seam; - 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 @@ -238,8 +240,9 @@ shape to evaluate locally is: - 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. +The initial workspace decision is recorded in ADR 0001. Exedra subsequently +proved the rooted-tree runtime boundary recorded in ADR 0002; that boundary +does not imply a universal graph evaluator or storage engine. ## 12. Initial complete vertical slice diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index a9cb8dd..b1c9452 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -63,3 +63,27 @@ was observed. Dynamic callers should copy those fields into `DynamicTransaction::{selection_space, selection_revision}` and `DynamicGuard::{expected_space, expected_revision}` instead of reaching through the adapter to typed host state. + +## Tree runtime revisions and host iteration + +`TreeRuntime::into_host` now returns `(Revision, H)`. Restore that pair with +`TreeRuntime::resume(revision, host)`; do not call `new` with an id belonging to +an existing space instance. Apply already-validated mutations through +`TreeRuntime::commit` so the revision advances without cloning the whole host. + +`TreeHost::nodes` and `TreeHost::children` now return iterators instead of +appending to output vectors. Referent and occurrence identities must implement +`Ord`, and hosts may override `occurrences_of` to use an index. A blanket +implementation makes `&H` a read-only tree host whenever `H` is one. + +## Resolution and live change exhaustiveness + +`Resolution` is now exhaustive and accepts an optional fifth type parameter +for the `CapabilityUnavailable` payload; it defaults to `String`. Match all +variants directly, and select a domain capability type when free-form text is +not appropriate. + +`QueryChange::Rebound` was removed because generic snapshot differencing could +not produce it without typed referent evidence. Use `Updated` for observable +value changes; a future rebound event must carry enough typed identity for the +producer and replay logic to agree on its meaning. diff --git a/docs/adr/0002-tree-runtime-from-exedra.md b/docs/adr/0002-tree-runtime-from-exedra.md new file mode 100644 index 0000000..49fafda --- /dev/null +++ b/docs/adr/0002-tree-runtime-from-exedra.md @@ -0,0 +1,63 @@ +# ADR 0002: Extract a reusable rooted-tree runtime from Exedra evidence + +- Status: accepted +- Date: 2026-08-27 + +## Context + +The first real consumer, `exedra_assembly`, already owns a rooted instance +forest, stable occurrence paths, part referents, and runtime-local handles. A +consumer-local implementation of Addressable resolution and queries duplicated +the reference host's assembly orchestration while leaving Exedra's old path +resolution and selection helpers intact. That integration added vocabulary and +code but did not replace the narrower API it was meant to supersede. + +The architecture anticipated specialized host evaluation and prohibited a +compulsory graph store. It also allowed a genuine consumer to reveal a shared +host seam. Rooted canonical-address trees are now such a seam: the host can own +storage and indexes while a reusable evaluator owns exact/relative/pinned +resolution and the common query policies. + +## Decision + +Add `addressable_tree`, an always `no_std + alloc` crate depending only on +`addressable`. It owns `TreeRuntime`, `TreeHost`, `TreeNode`, and `TreeAxis`. + +`TreeHost` projects host-owned nodes by view, exact address, occurrence, +parent/child relationships, predicate matching, and optional runtime handles. +It does not prescribe storage, indexing, domain values, or mutation. The +runtime binds a host value to a `SpaceId` and revision. Hosts expose lazy node +iterators and may override referent occurrence lookup with an index. Referent +and occurrence identities are ordered so cycle detection and deduplication use +`BTreeSet` rather than quadratic vector scans. The runtime implements: + +- exact and relative locator resolution; +- pinned staleness, rebinding, movement, and ambiguity; +- children, descendants, and parent queries; +- cardinality, ordering, occurrence/referent deduplication, cycle policy, and + traversal budgets; +- validated revision-scoped runtime handles. + +Cardinality shaping through `Measured` and budget charging through `QueryStats` +remain in `addressable`, because both tree and specialized graph evaluators use +those host-independent query laws. + +The runtime exposes immutable host access and an infallible in-place commit +that advances its revision once. Extraction returns the revision with the host, +and `resume` restores that clock. Domain transactions remain responsible for +validating fallible preconditions before commit. A drop guard advances the +clock during unwinding as well, so a caught panic cannot expose a partially +mutated host at its old revision. + +## Consequences + +- Exedra can store `AbsoluteAddress` directly, delete its custom + path traversal, and implement only a small node projection. +- The Basilica assembly view is a second `TreeHost` and executes pure tree + queries through the same runtime. Its dependency and cross-view axes retain + their specialized evaluator. +- Non-tree relationship views retain specialized evaluators; this runtime does + not force the reference dependency graph into a tree abstraction. +- The dependency-free `addressable` semantic nucleus remains unchanged. +- Watches can later recompute the same typed tree query through this runtime, + but live-query scheduling is not pulled into this slice.