diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index 68228073..e6e62812 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -1,3 +1,15 @@ +## 2026-06-18 — lite-unified-surrealql-lance-v1 (one store + one query surface, feature-gated; CONJECTURE, test-don't-commit) + +**Status:** CONJECTURE / design — feature-gated test path, NOT a default-build change. **Plan file:** `.claude/plans/lite-unified-surrealql-lance-v1.md`. +**Owns:** the "lite unified" bet — collapse the two query engines (datafusion + SurrealQL) + two stores (lance + rocksdb) to **ONE store (lance-KV) + ONE primary query surface (SurrealQL/AR-API)**, datafusion feature-gated (`datafusion-analytical`), rocksdb dropped; the DO-arm `ExecTarget::SurrealQl` becomes the primary exec path. +- **Win** (graph/AR/CRUD/cognitive/vector): Cypher→SurrealQL is a better lowering than Cypher→datafusion-SQL (surreal is natively graph); drops the rocksdb C++ build + makes datafusion optional. **Downgrade** (heavy analytical SQL): datafusion's strength → kept feature-gated, not deleted. +- **Falsifier (truth-architect):** lance-graph `datafusion_planner` test queries → can SurrealQL express each? Covered → drop datafusion for that path; gaps → keep `datafusion-analytical`. Measure footprint (proxy: lance-graph ≈889 crates, surreal-all ≈1148, SurrealQL-engine marginal ~260, rocksdb separate C++). +- **Blockers (OQ-LU-1/2/3):** surreal kv-lance not yet feature-wired (`surrealdb/core/src/kvs/lance/` module implemented, no `kv-lance` feature); polyglot→SurrealQL lowering doesn't exist (today polyglot→datafusion); SPARQL/Gremlin lowering cleanliness unknown. +**Gate before any promotion:** convergence + cross-domain (mechanism-vs-rhyme) + truth-architect (query-shape coverage). Do NOT touch the default build until green. +**Repos:** lance-graph (+ surrealdb fork for kv-lance). Surfaced from the footprint discussion (drop datafusion+rocksdb) on branch `claude/soa-write-deinterlace-inc2`. + +--- + ## 2026-06-18 — mailbox-belief-update-and-substrate-test-v1 ("what did I learn" = NARS-revision delta + two-axis test; 5+3-ratified; slots S2.5b) **Status:** CONJECTURE / design — 5+3 COMPLETE. **Plan file:** `.claude/plans/mailbox-belief-update-and-substrate-test-v1.md`. Parent: `bindspace-singleton-to-mailbox-soa-v1` §11 + `E-SOA-CYCLE-OWNERSHIP`. diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 42df4f73..d2f92444 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -89,6 +89,8 @@ ## Current Contract Inventory (lance-graph-contract) +> **2026-06-18 — ADDED (D-DO-ARM-1, the OGAR DO arm)**: `lance_graph_contract::action::{ActionState, StateGuard, ActionDef, ClassActions, actions_for, effective_actions, ActionInvocation}` — the Perdurant DO arm completing the OGAR IR (the action-axis sibling of `codegen_manifest`'s `MethodSig`/THINK). Both the 4-agent `sale_order` AR→DO probe (runtime-archaeologist) AND the merged cross-repo PR survey (ruff/OGAR/lance-graph/openproject/tesseract) agreed this was the ONE missing wire: the THINK arm (`classid → ClassView`, `has_function → MethodSig`) is converged + merged; the DO-arm `ActionInvocation`/`ActionDef` type was ABSENT. **`ActionDef`** (static, `const`-constructible, all `&'static`/`Copy`): `predicate` (= harvested `has_function` method), `object_class` (classid), `exec` (`ExecTarget` incl `SurrealQl`), `guard` (`StateGuard` = KausalSpec field==value), `required_role` (RBAC), `overrides` (OGAR `classid→ClassView` inheritance). **`ClassActions`+`actions_for`** (zero-fallback) mirror `ClassMethods`/`methods_for`. **`effective_actions(parent, child)`** = OGAR inheritance on the action axis (child overrides parent by predicate). **`ActionInvocation`** (dynamic, `Copy`): lifecycle `ActionState{Pending→Committed|Failed|Cancelled}` (sticky terminals), S2.5 `cycle` stamp, idempotency/trace keys, HLC `emitted_at_millis`. **`ActionInvocation::commit(def, actor, impact, now)`** is the gated egress — RBAC FIRST (`auth::ActorContext` must hold `required_role` or be admin → else `Failed`), THEN MUL impact (`mul::GateDecision`: `Flow→Committed`+stamped, `Hold→`Pending/escalate, `Block→Cancelled`). This IS "commit to the external consumer (odoo/openproject/woa/tesseract) after the cycle decides sound." Dispatched via `UnifiedStep`/`ExecTarget`, NOT a per-crate endpoint. Additive, zero-dep. +5 tests green. Consumer reference: `docs/OGAR_CONSUMER_API.md`. Branch `claude/soa-write-deinterlace-inc2`. + > **2026-06-18 — ADDED (D-UNICHARSET-KEYSTONE, classid → ClassView → adapter wiring)**: `lance_graph_contract::unicharset_adapter::{UniCharSetStore, UniCharCall, UniCharOut, DispatchError, invoke_unicharset}` — steps 2–3 of `PROBE-OGAR-ADAPTER-UNICHARSET`, the keystone composing the proven `UniCharSet` adapter through the OGAR Core's three movable parts. `invoke_unicharset(registry, store, classid, call)`: (1) **ClassView composition gate** — `codegen_manifest::methods_for(registry, classid)` must list the call's method (the harvested `has_function` manifest), else `MethodNotComposed` (zero-fallback: an unconfigured classid composes nothing); (2) **content-store tier** — `UniCharSetStore::unicharset(classid)`, a consumer-provided trait (dependency-inverted like `ClassView`/`PlannerContract`; the adapter holds NO state — `I-VSA-IDENTITIES`); (3) **adapter leaf** — routes to `UniCharSet::{id_to_unichar, unichar_to_id}`. DO-in (`UniCharCall`) / DO-out (`UniCharOut`, zero-copy borrow). **Byte-parity inherited** from `UniCharSet` (112/112); the keystone proves the dispatch path is faithful (the `NULL`→space edge survives it), the gate works, and there is **no Core gap** (the doctrine's iron guard holds — the variable-length bijection rides the content tier cleanly). NOT routed through the heavy `OrchestrationBridge` (cross-subsystem router); this is the adapter-invocation primitive a `UnifiedStep` calls. Additive, zero-dep. +5 tests; clippy `--all-targets -D warnings` + fmt clean. Completes the core-first doctrine END-TO-END for the unicharset leaf (`E-CPP-KEYSTONE-1`). > **2026-06-17 — ADDED (D-UNICHAR-1, SECOND byte-parity adapter)**: `lance_graph_contract::unichar::{utf8_step, utf8_to_utf32}` — the Tesseract `UNICHAR` UTF-8 codec that `UNICHARSET` sits on top of (`ccutil/unichar.cpp`). `utf8_step(lead) -> u8` is a `const fn` transcription of Tesseract's 256-entry lead-byte table (1/2/3/4 for legal leads, 0 for continuation bytes `0x80..=0xBF` + `0xF8..`); `utf8_to_utf32(bytes) -> Option>` mirrors `UNICHAR::UTF8ToUTF32` (lead-byte validation only, `None` on an illegal lead). **The second adapter through the transcode pipeline, byte-parity proven**: `examples/unichar_dump.rs` vs a libtesseract `UNICHAR` oracle is **268/268 identical** (256 EXHAUSTIVE `utf8_step` lead-byte values + 12 `utf8_to_utf32` corpus rows). Faithful-transcode note (the point of the exercise): Tesseract maps `0xC0`/`0xC1` to step 2 and decodes the overlong NUL `C0 80` to `[0]`; `core::str::from_utf8` REJECTS both, so a native-UTF-8 shortcut would silently diverge — mirroring the exact table is mandatory (`from_utf8_rejects_what_tesseract_accepts` test pins it). Additive, zero-dep, pure text (no leptonica). +8 tests + the `unichar_dump` example; 653 contract lib green; clippy `--all-targets -D warnings` clean. Sibling of D-UNICHARSET-1, same `PROBE-OGAR-ADAPTER-UNICHARSET` falsifier family (E-CPP-PARITY-2). diff --git a/.claude/plans/lite-unified-surrealql-lance-v1.md b/.claude/plans/lite-unified-surrealql-lance-v1.md new file mode 100644 index 00000000..b818a579 --- /dev/null +++ b/.claude/plans/lite-unified-surrealql-lance-v1.md @@ -0,0 +1,81 @@ +# lite-unified-surrealql-lance-v1 — one store + one query surface, behind a feature gate + +> **Status:** CONJECTURE / design. **Test via feature gate; do NOT commit the +> stack change.** Needs a convergence + cross-domain + truth-architect probe +> (mechanism-vs-rhyme + the query-shape measurement) before any promotion. +> **Date:** 2026-06-18. **Parent threads:** the DO-arm (`ExecTarget::SurrealQl`, +> `lance-graph-contract::action`), `docs/STACK_SCAFFOLD.md`, the +> "cold TS + kanban stay Lance-native" ruling. + +## Epiphany (less is more) + +Today there are **two query engines over the same lance storage** (lance-graph's +*datafusion* planner + surreal's *SurrealQL*) and **two storage engines** +(lance vs rocksdb). The "lite unified" bet collapses both: **ONE store (lance-KV) ++ ONE primary query surface (SurrealQL via the AR-API adapter)**, datafusion +**feature-gated**, rocksdb **dropped**. Cypher/SQL/neo4j lower to SurrealQL — +which is *natively* graph (`->edge->`), a better target than Cypher→datafusion-SQL. + +## The bet, as a feature gate (default-OFF) + +A `lite-unified` feature that, when ON: +1. **Storage = surreal kv-lance** (one store; drop rocksdb). *Blocked on:* surreal + kv-lance is implemented as a module but not yet feature-wired + (`surrealdb/core/src/kvs/lance/`, the `.claude/lance-backend` integration). +2. **Query/exec = SurrealQL** via the AR-API adapter. The polyglot parser + (Cypher/GQL/Gremlin/SPARQL/neo4j) lowers to **SurrealQL** (or the DO-arm + `ActionInvocation`) instead of datafusion SQL. *Missing today:* the + polyglot→SurrealQL lowering (today it's polyglot→datafusion). +3. **datafusion = `optional`, OFF** on this path. Kept behind a separate + `datafusion-analytical` feature for the workloads that genuinely need + vectorized/analytical SQL (joins, aggregations) — SurrealQL's weak spot. +4. The DO-arm `ExecTarget::SurrealQl` becomes the **primary** exec path, not one + of four. + +## What stays regardless (NOT datafusion) + +lance vector search, CAM-PQ / bgz17 codec stack, the cognitive substrate +(BindSpace→MailboxSoA, the write contract, the SPO/AriGraph tissue). These are +orthogonal to the query-engine choice. + +## Where it's a win vs a downgrade (the honest split) + +- **Win (the bulk):** graph traversal, AR CRUD, cognitive/SPO, vector search — + SurrealQL-on-lance fits, and Cypher→SurrealQL graph is a *better* lowering. + Footprint: drop the rocksdb C++ build outright; make datafusion (a large Rust + dep) optional. +- **Downgrade:** heavy analytical SQL (multi-way joins, aggregations, columnar + scan) — datafusion's strength, SurrealQL's weakness. Hence datafusion stays + feature-gated, not deleted. + +## Falsifier (truth-architect — measure before promoting) + +Take lance-graph's `datafusion_planner` test queries (the Cypher→SQL cases) and +check **SurrealQL can express each**. Covered → drop datafusion for that path; +analytical gaps → keep `datafusion-analytical` for those only. Also measure the +real footprint delta (`cargo tree --no-default-features` + release `cargo bloat`) +once kv-lance is feature-wired — the proxy is lance-graph ≈ 889 crates, surreal +(all backends) ≈ 1148; the marginal SurrealQL-engine cost is ~260 crates, rocksdb +is a separate C++ build. + +## Increments (all behind `lite-unified`, none committed to the default path) + +1. **Probe (no code):** convergence + cross-domain (mechanism-vs-rhyme) + + truth-architect (the datafusion_planner query-shape coverage check). Gate. +2. **Wire surreal kv-lance** as a feature (finish the `.claude/lance-backend` + integration; add the `kv-lance` feature + lance dep + `mod lance` in `kvs/mod.rs`). +3. **Polyglot→SurrealQL lowering** — the missing front-end leg (parallel to the + existing polyglot→datafusion). +4. **`datafusion` → `optional`** + a `datafusion-analytical` feature; default the + common path to SurrealQL-on-lance under `lite-unified`. +5. **Measure** footprint + query-shape coverage; promote CONJECTURE→FINDING or + correct. + +## Blockers / open questions + +- **OQ-LU-1:** surreal kv-lance feature-wiring (the integration TODOs). +- **OQ-LU-2:** does SurrealQL cover the lance-graph datafusion_planner query + shapes the live workloads actually use? (the falsifier). +- **OQ-LU-3:** is the polyglot→SurrealQL lowering cleaner than polyglot→datafusion + for the non-graph dialects (SPARQL/Gremlin)? +- Do NOT touch the default build until the probe is green. diff --git a/crates/cognitive-shader-driver/src/backing.rs b/crates/cognitive-shader-driver/src/backing.rs index 62787743..37ad2a04 100644 --- a/crates/cognitive-shader-driver/src/backing.rs +++ b/crates/cognitive-shader-driver/src/backing.rs @@ -46,6 +46,7 @@ use lance_graph_contract::cognitive_shader::{ColumnWindow, MetaFilter}; use crate::bindspace::BindSpace; #[cfg(feature = "mailbox-thoughtspace")] use crate::mailbox_soa::MailboxSoA; +use crate::mailbox_soa::{WriteCell, WriteOutcome}; /// Read-only substrate the dispatch hot path sweeps. /// @@ -256,6 +257,60 @@ impl BackingStoreWrite<'_> { BackingStoreWrite::Mailbox(mb) => mb.set_sigma(row, s), } } + + /// Cycle-aware row write (S2.5 deinterlacing) — routes a [`WriteCell`] + /// through the per-mailbox cycle gate. + /// + /// - **Mailbox arm:** delegates to [`MailboxSoA::write_row`], which gates the + /// write (wrap-aware) against the mailbox's `current_cycle`: a stale batch + /// never overwrites a row the owner advanced past. + /// - **Singleton arm:** **cycle-blind BY CONSTRUCTION** (CATCH-CRITICAL, + /// baton-handoff). `BindSpace` owns no `current_cycle`, so it cannot gate; + /// it applies the cell's present fields via the per-field setters and + /// returns [`WriteOutcome::Accepted`] unconditionally. The cycle gate is a + /// Mailbox-only guarantee until W7 deletes `BindSpace`. `topic`/`angle` are + /// Mailbox-only on the write shim; the legacy singleton path does not carry + /// them (it has no dense-plane setter on this surface). + #[inline] + pub(crate) fn write_row( + &mut self, + row: usize, + cycle: u32, + cell: &WriteCell<'_>, + ) -> WriteOutcome { + #[cfg(feature = "mailbox-thoughtspace")] + if let BackingStoreWrite::Mailbox(mb) = self { + return mb.write_row(row, cycle, cell); + } + // Singleton arm: cycle-blind by construction. The `cycle` argument is + // intentionally unused here (no clock to compare against). + let _ = cycle; + if let Some(w) = cell.content { + self.set_content(row, w); + } + if let Some(q) = cell.qualia { + self.set_qualia(row, q); + } + if let Some(e) = cell.edge { + self.set_edge(row, e); + } + if let Some(m) = cell.meta { + self.set_meta(row, m); + } + if let Some(t) = cell.entity_type { + self.set_entity_type(row, t); + } + if let Some(t) = cell.temporal { + self.set_temporal(row, t); + } + if let Some(x) = cell.expert { + self.set_expert(row, x); + } + if let Some(s) = cell.sigma { + self.set_sigma(row, s); + } + WriteOutcome::Accepted + } } #[cfg(test)] diff --git a/crates/cognitive-shader-driver/src/mailbox_soa.rs b/crates/cognitive-shader-driver/src/mailbox_soa.rs index 34bc5412..a5e4eccc 100644 --- a/crates/cognitive-shader-driver/src/mailbox_soa.rs +++ b/crates/cognitive-shader-driver/src/mailbox_soa.rs @@ -77,6 +77,14 @@ pub struct MailboxSoA { /// rows already stamped this cycle are skipped. pub last_active_cycle: [u32; N], + /// Per-row last-**write** cycle stamp (S2.5 cycle-aware write contract). + /// Distinct from `last_active_cycle` (consumption): this records the cycle + /// at which [`Self::write_row`] last accepted a write into the row. Kept + /// separate so the write gate's *ordering* check never couples to + /// `consume_firing`'s *exact-match* idempotency guard (`I-LEGACY-API-FEATURE-GATED`: + /// same field, two semantics is forbidden). Sentinel `u32::MAX` = never written. + pub last_write_cycle: [u32; N], + // ── NEW: migrated thoughtspace columns (per-mailbox owned, D-MBX-A1) ── /// Per-row LE baton edge (`CausalEdge64`, 8 B/row). /// Migrated from `BindSpace.edges` (EdgeColumn). @@ -147,6 +155,13 @@ pub struct MailboxSoA { /// Monotonic cycle stamp; advanced by `tick()`. pub current_cycle: u32, + /// Count of writes rejected as stale by [`Self::write_row`] (telemetry). + /// A late batch targeting a cycle the mailbox already advanced past is + /// dropped, not applied — this counts those drops (drop-with-telemetry = + /// the Strict `WriteDisposition`; an Aware local buffer is a future option). + /// Mailbox-level (not per-row); [`Self::reset_row`] does NOT touch it. + pub(crate) stale_write_count: u64, + /// 6-bit W-slot value this mailbox represents. /// Incoming edges with `edge.w_slot() != self.w_slot` are rejected. /// Must be < 64 (plan §6 L-6). @@ -194,6 +209,56 @@ pub struct MailboxSoA { /// Default capacity: 1024 rows (4× current BindSpace row count). pub type DefaultMailboxSoA = MailboxSoA<1024>; +/// Outcome of a cycle-aware row write through [`MailboxSoA::write_row`]. +/// +/// Infallible-with-outcome (NOT `Result`): ownership is compile-proven +/// (`&mut self`, E-CE64-MB-4), so "this write is for another cycle" is a valid +/// in-domain *outcome*, not an aliasing failure (council OQ-D). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteOutcome { + /// `cycle == current_cycle` — the `cell` was applied and `last_write_cycle[row]` + /// stamped to `cycle`. + Accepted, + /// `cycle` is (wrap-aware) strictly behind `current_cycle` — a late batch + /// targeting a cycle the mailbox advanced past. Nothing mutated; + /// `stale_write_count` incremented. This is the "nothing buffers a stale + /// mailbox / no cycle-blind overwrite" guarantee. + Stale, + /// `cycle` is (wrap-aware) strictly ahead of `current_cycle`. Nothing + /// mutated (Strict `WriteDisposition` default; an Aware buffer is a future + /// option for the multi-producer interlace target). + Future, +} + +/// Field-presence staging cell for one cycle-aware row write. +/// +/// Only `Some` fields are applied; `None` fields are left untouched. It is a +/// *view over what to write*, carrying no column data it does not need +/// (register-laziness, `I-VSA-IDENTITIES`). Slices borrow; scalars copy. +#[derive(Debug, Clone, Default)] +pub struct WriteCell<'a> { + /// Content identity plane (`WORDS_PER_FP` u64) — borrowed. + pub content: Option<&'a [u64]>, + /// Topic identity plane (`WORDS_PER_FP` u64) — borrowed. + pub topic: Option<&'a [u64]>, + /// Angle identity plane (`WORDS_PER_FP` u64) — borrowed. + pub angle: Option<&'a [u64]>, + /// LE baton edge. + pub edge: Option, + /// Affective i4-16D vector. + pub qualia: Option, + /// Packed meta word. + pub meta: Option, + /// OGIT entity-type index. + pub entity_type: Option, + /// Temporal stamp. + pub temporal: Option, + /// Expert/corpus id. + pub expert: Option, + /// Σ-codebook index. + pub sigma: Option, +} + impl MailboxSoA { /// Construct a new `MailboxSoA` with all per-row state zero-initialised. /// @@ -215,7 +280,10 @@ impl MailboxSoA { // u32::MAX during a session, so first consumption on any cycle is // always permitted (u32::MAX != any valid cycle stamp). last_active_cycle: [u32::MAX; N], + // u32::MAX = "never written" sentinel (mirrors last_active_cycle). + last_write_cycle: [u32::MAX; N], current_cycle: 0, + stale_write_count: 0, w_slot, threshold, // ── NEW thoughtspace columns — zero-initialised (D-MBX-A1) ── @@ -305,6 +373,80 @@ impl MailboxSoA { self.current_cycle = self.current_cycle.wrapping_add(1); } + /// Cycle-aware row write — the ONE deinterlacing mutator (S2.5). + /// + /// The gate is **wrap-aware** against `current_cycle` (a naive `<`/`>` + /// misclassifies post-`u32`-wrap stragglers as `Future` across a long + /// interlaced sweep): + /// - `cycle == current_cycle` → apply the `Some` fields of `cell`, stamp + /// `last_write_cycle[row] = cycle`, return [`WriteOutcome::Accepted`]. + /// - `cycle` strictly behind (wrapping distance `< 2^31`) → mutate nothing, + /// increment `stale_write_count`, return [`WriteOutcome::Stale`]. + /// - `cycle` strictly ahead → mutate nothing, return [`WriteOutcome::Future`]. + /// + /// Out-of-range `row` returns `Stale` without mutation (a row we do not own + /// is never written). This is the cycle-blind-setter gap closed: no write + /// lands without the owner's `current_cycle` agreeing. + pub fn write_row(&mut self, row: usize, cycle: u32, cell: &WriteCell<'_>) -> WriteOutcome { + if row >= N { + return WriteOutcome::Stale; + } + // Wrap-aware: delta in [0, 2^31) ⇒ cycle is at-or-behind current + // (0 = current, >0 = stale); [2^31, 2^32) ⇒ cycle is ahead (future). + let delta = self.current_cycle.wrapping_sub(cycle); + if delta == 0 { + if let Some(w) = cell.content { + self.set_content(row, w); + } + if let Some(w) = cell.topic { + self.set_topic(row, w); + } + if let Some(w) = cell.angle { + self.set_angle(row, w); + } + if let Some(e) = cell.edge { + self.set_edge(row, e); + } + if let Some(q) = cell.qualia { + self.set_qualia(row, q); + } + if let Some(m) = cell.meta { + self.set_meta(row, m); + } + if let Some(t) = cell.entity_type { + self.set_entity_type(row, t); + } + if let Some(t) = cell.temporal { + self.set_temporal(row, t); + } + if let Some(x) = cell.expert { + self.set_expert(row, x); + } + if let Some(s) = cell.sigma { + self.set_sigma(row, s); + } + self.last_write_cycle[row] = cycle; + WriteOutcome::Accepted + } else if delta < 0x8000_0000 { + self.stale_write_count = self.stale_write_count.saturating_add(1); + WriteOutcome::Stale + } else { + WriteOutcome::Future + } + } + + /// Per-row last-**write** cycle stamp (`u32::MAX` = never written). + #[inline] + pub fn last_write_cycle_at(&self, row: usize) -> u32 { + self.last_write_cycle[row] + } + + /// Count of writes rejected as stale by [`Self::write_row`] (telemetry). + #[inline] + pub fn stale_write_count(&self) -> u64 { + self.stale_write_count + } + /// Declared populated-row count (W1c) — the `BindSpace::len` analogue, NOT the /// type-level capacity `N`. Row-bounded sweeps (the migration read-shim's /// `meta_prefilter`) clamp to this logical size, so zeroed padding rows @@ -341,6 +483,9 @@ impl MailboxSoA { // Restore the "never consumed" sentinel so the row can fire immediately // on the next cycle without triggering the same-cycle guard. self.last_active_cycle[row] = u32::MAX; + // Restore the "never written" sentinel (field-isolation: a new [u32; N] + // that reset_row forgets is the exact leak the matrix test catches). + self.last_write_cycle[row] = u32::MAX; // ── NEW thoughtspace columns reset (D-MBX-A1) ── self.edges[row] = CausalEdge64::ZERO; self.qualia[row] = QualiaI4_16D::ZERO; @@ -634,7 +779,11 @@ impl MailboxSoaOwner for MailboxSoA { to, // Structural witness position (R4): the monotonic cycle stamp stands in // for the chain index until the witness_arc column lands — matching - // `NextPhaseScheduler`'s convention. + // `NextPhaseScheduler`'s convention. Read it as the SoA cycle-ownership + // stamp via `KanbanMove::cycle()` (S2.5) — makes the move + planner + + // SurrealQL exec cycle-aware off one source of truth. (No "emission": + // the mailbox writes to itself in place; this is its own lifecycle + // step recorded at its own current_cycle, per the #477 three-tier model.) witness_chain_position: self.current_cycle, libet_offset_us: if from == KanbanColumn::Planning && to == KanbanColumn::CognitiveWork { diff --git a/crates/cognitive-shader-driver/tests/substrate_sanity.rs b/crates/cognitive-shader-driver/tests/substrate_sanity.rs new file mode 100644 index 00000000..f0032e26 --- /dev/null +++ b/crates/cognitive-shader-driver/tests/substrate_sanity.rs @@ -0,0 +1,211 @@ +//! Substrate sanity harness — "is the substrate NaN-free and non-tautological?" +//! +//! Two failure classes this guards against: +//! +//! 1. **NaN/Inf** — any f32 surface (qualia f32 projection, energy accumulator) +//! that produces a non-finite value silently poisons every downstream +//! cosine / distance / free-energy read. +//! 2. **Tautology** — a substrate operation that is *trivially true*: a write +//! gate that always accepts, a qualia projection that collapses every input +//! to one vector, a write that ignores its field-presence. A green test that +//! only ever exercises the trivial path certifies nothing. +//! +//! These run on the DEFAULT build (no feature needed): `MailboxSoA` is an +//! unconditional `pub mod`, and the cycle-aware `write_row` gate lives on it. + +use causal_edge::CausalEdge64; +use cognitive_shader_driver::mailbox_soa::{MailboxSoA, WriteCell, WriteOutcome, WORDS_PER_FP}; +use lance_graph_contract::cognitive_shader::MetaWord; +use lance_graph_contract::qualia::QualiaI4_16D; + +fn content_plane(seed: u64) -> Vec { + let mut c = vec![0u64; WORDS_PER_FP]; + c[0] = seed; + c[WORDS_PER_FP - 1] = seed.wrapping_mul(0x9E37_79B9); + c +} + +// ─────────────────────────────── NaN / Inf ──────────────────────────────── + +/// The qualia f32 projection is finite for every i4 value across every dim — +/// including the signed extremes. A NaN here poisons all cosine reads. +#[test] +fn qualia_f32_projection_is_finite_over_full_i4_range() { + for dim in 0..16usize { + for v in -8i8..=7 { + let q = QualiaI4_16D::ZERO.with(dim, v); + let f = q.to_f32_17d(); + for (i, x) in f.iter().enumerate() { + assert!( + x.is_finite(), + "qualia dim={dim} val={v} produced non-finite f32 at out[{i}] = {x}" + ); + } + } + } + // All dims at the extreme simultaneously. + let mut q = QualiaI4_16D::ZERO; + for dim in 0..16usize { + q = q.with(dim, if dim % 2 == 0 { 7 } else { -8 }); + } + assert!( + q.to_f32_17d().iter().all(|x| x.is_finite()), + "all-extreme qualia produced a non-finite f32" + ); +} + +/// The energy accumulator stays finite through write/consume; `consume_firing` +/// resets a fired row to a finite 0.0 (not NaN). +#[test] +fn energy_accumulator_stays_finite_through_consume() { + let mut mb: MailboxSoA<8> = MailboxSoA::new(0, 0, 1.0); + mb.set_populated(8); + mb.energy[0] = 3.5; + mb.energy[1] = -2.0; + assert!(mb.energy.iter().all(|e| e.is_finite())); + + assert!(mb.consume_firing(0), "row 0 above threshold should fire"); + assert!( + mb.energy.iter().all(|e| e.is_finite()), + "consume must leave all energies finite" + ); + assert_eq!(mb.energy_at(0), 0.0, "fired row resets to a finite 0.0"); +} + +// ─────────────────────────────── Tautology ──────────────────────────────── + +/// THE core anti-tautology: the cycle gate must DISCRIMINATE — accept the +/// current cycle, reject stale (older) and future (newer). A gate that always +/// returns `Accepted` is a tautology that re-opens the stale-overwrite hole. +#[test] +fn write_gate_discriminates_current_stale_future() { + let mut mb: MailboxSoA<8> = MailboxSoA::new(0, 0, 1.0); + mb.set_populated(8); + + let m_a = MetaWord::new(1, 1, 100, 100, 0); + let cell_a = WriteCell { + meta: Some(m_a), + ..Default::default() + }; + + // current_cycle == 0: accepted, stamped. + assert_eq!(mb.write_row(0, 0, &cell_a), WriteOutcome::Accepted); + assert_eq!(mb.last_write_cycle_at(0), 0); + assert_eq!(mb.meta_at(0), m_a, "accepted write applied the cell"); + + // Advance to cycle 1; a write for cycle 0 is now STALE — must NOT apply. + mb.tick(); + let m_stale = MetaWord::new(2, 2, 200, 200, 0); + let cell_stale = WriteCell { + meta: Some(m_stale), + ..Default::default() + }; + assert_eq!(mb.write_row(0, 0, &cell_stale), WriteOutcome::Stale); + assert_eq!(mb.stale_write_count(), 1, "stale write counted"); + assert_eq!( + mb.meta_at(0), + m_a, + "STALE write must NOT overwrite the row (no cycle-blind clobber)" + ); + + // A write for cycle 5 (current is 1) is FUTURE — must NOT apply. + let cell_future = WriteCell { + meta: Some(MetaWord::new(3, 3, 50, 50, 0)), + ..Default::default() + }; + assert_eq!(mb.write_row(0, 5, &cell_future), WriteOutcome::Future); + assert_eq!(mb.meta_at(0), m_a, "FUTURE write must NOT apply"); + assert_eq!(mb.stale_write_count(), 1, "future is not counted as stale"); +} + +/// The gate is wrap-aware: after `current_cycle` wraps past `u32::MAX`, a write +/// from the pre-wrap epoch is still classified STALE (not mis-read as Future). +#[test] +fn write_gate_is_wrap_aware() { + let mut mb: MailboxSoA<4> = MailboxSoA::new(0, 0, 1.0); + mb.set_populated(4); + // Force the clock near the wrap boundary. + mb.current_cycle = 1; // pretend we just wrapped 0xFFFF_FFFF -> 0 -> 1 + let cell = WriteCell { + meta: Some(MetaWord::new(1, 1, 10, 10, 0)), + ..Default::default() + }; + // A straggler stamped 0xFFFF_FFFF (one epoch behind) must be STALE, not Future. + assert_eq!( + mb.write_row(0, u32::MAX, &cell), + WriteOutcome::Stale, + "pre-wrap straggler must be stale, not future" + ); +} + +/// Field-presence is honoured: a `WriteCell` that sets only `meta` must leave +/// `qualia` untouched. A write that clobbers every column regardless of +/// presence is a tautology. +#[test] +fn write_cell_field_presence_is_not_a_tautology() { + let mut mb: MailboxSoA<4> = MailboxSoA::new(0, 0, 1.0); + mb.set_populated(4); + let q0 = mb.qualia_at(0); + let cell = WriteCell { + meta: Some(MetaWord::new(5, 5, 1, 1, 0)), + ..Default::default() // qualia/content/edge = None + }; + assert_eq!(mb.write_row(0, 0, &cell), WriteOutcome::Accepted); + assert_eq!( + mb.qualia_at(0), + q0, + "a meta-only WriteCell must NOT touch qualia (field-presence honoured)" + ); +} + +/// Writes carry DISTINCT data: two rows written with different content read back +/// different. A substrate that collapses distinct writes to one value is a +/// degenerate (tautological) store. +#[test] +fn distinct_writes_read_back_distinct() { + let mut mb: MailboxSoA<4> = MailboxSoA::new(0, 0, 1.0); + mb.set_populated(4); + let c0 = content_plane(0x1111); + let c1 = content_plane(0x2222); + let w0 = WriteCell { + content: Some(&c0), + ..Default::default() + }; + let w1 = WriteCell { + content: Some(&c1), + ..Default::default() + }; + assert_eq!(mb.write_row(0, 0, &w0), WriteOutcome::Accepted); + assert_eq!(mb.write_row(1, 0, &w1), WriteOutcome::Accepted); + assert_ne!( + mb.content_row(0), + mb.content_row(1), + "distinct content writes must NOT collapse to one value" + ); + assert_eq!(mb.content_row(0), &c0[..]); +} + +/// The qualia projection discriminates: opposite i4 inputs map to different +/// f32 vectors (the projection is not a constant tautology). +#[test] +fn qualia_projection_discriminates() { + let pos = QualiaI4_16D::ZERO.with(0, 5).to_f32_17d(); + let neg = QualiaI4_16D::ZERO.with(0, -5).to_f32_17d(); + assert_ne!(pos, neg, "opposite qualia must not project to the same f32"); +} + +/// An edge with zero mantissa contributes zero energy (sanity: the accumulator +/// is not fabricating energy from nothing — would be a tautological "always +/// firing" substrate). +#[test] +fn zero_edge_contributes_no_energy() { + let mut mb: MailboxSoA<4> = MailboxSoA::new(0, 0, 1.0); + mb.set_populated(4); + let accepted = mb.apply_edges(&[(0u16, CausalEdge64::ZERO)]); + assert_eq!(accepted, 1, "w_slot-0 zero edge is accepted (same corpus)"); + assert_eq!( + mb.energy_at(0), + 0.0, + "a zero-mantissa edge must add zero energy (no fabricated firing)" + ); +} diff --git a/crates/lance-graph-contract/src/action.rs b/crates/lance-graph-contract/src/action.rs new file mode 100644 index 00000000..c9000b2e --- /dev/null +++ b/crates/lance-graph-contract/src/action.rs @@ -0,0 +1,579 @@ +//! `action` — the **DO arm** of the OGAR IR: `ActionDef` (static declaration) + +//! `ActionInvocation` (dynamic fire). The Perdurant complement of the Endurant +//! field-set: where `class_view`/`codegen_manifest` carry a class's *state* and +//! *method signatures* (THINK), this carries its *functional actions* (DO). +//! +//! Sourced from the SPO harvest's `has_function` rows (the Perdurant methods) +//! and shaped per OGAR `OGAR-AST-CONTRACT.md` §1. An `ActionInvocation` rides +//! the canonical [`crate::orchestration::UnifiedStep`] envelope to a +//! [`crate::kanban::ExecTarget`] (native / jit / **SurrealQL** / elixir) — it is +//! NOT a per-crate endpoint. +//! +//! # OGAR inheritance (classid → ClassView) +//! +//! A class's DO surface is **its own actions plus its parents'**, minus +//! overrides — the same `classid → ClassView` inheritance the field-set uses. +//! [`ActionDef::overrides`] names a parent-class action this one supersedes; +//! [`effective_actions`] composes the inherited set. No adapter carries its own +//! action table; the harvest IS the manifest (mirrors +//! [`crate::codegen_manifest`]). +//! +//! # The commit gate (RBAC + MUL) — why an action does not fire freely +//! +//! A DO action mutates an external domain consumer (odoo-rs / openproject / +//! woa-rs / tesseract-rs), so it is high-stakes: [`ActionInvocation::commit`] +//! advances `Pending → Committed` **only if** (a) the actor is RBAC-authorized +//! for the action's [`ActionDef::required_role`] ([`crate::auth::ActorContext`]), +//! AND (b) the MUL impact assessment ([`crate::mul::GateDecision`]) is `Flow`. +//! A `Hold` keeps it `Pending` (escalate / re-assess), a `Block` `Cancelled`, +//! and an unauthorized actor `Failed`. This IS the "commit to the external +//! consumer after the cycle decides the result sound" egress. + +use crate::auth::ActorContext; +use crate::canonical_node::NodeGuid; +use crate::kanban::ExecTarget; +use crate::mul::GateDecision; + +/// Lifecycle state of an [`ActionInvocation`] (OGAR `ActionStateKind`). +/// +/// `Pending → Committed | Failed | Cancelled`. The terminal states are sticky. +/// Maps onto the Rubicon commit boundary: an action is `Pending` until the +/// cycle decides the result sound (RBAC + MUL pass), then it commits OUT. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum ActionState { + /// Declared/fired, not yet adjudicated (or `Hold`-escalated). + #[default] + Pending = 0, + /// RBAC-authorized + MUL `Flow` — dispatched to the external consumer. + Committed = 1, + /// RBAC-unauthorized (or runtime failure). + Failed = 2, + /// MUL `Block` — impact judged unsound, refused. + Cancelled = 3, +} + +impl ActionState { + /// Whether this is a terminal (non-`Pending`) state. + #[must_use] + pub const fn is_terminal(&self) -> bool { + !matches!(self, ActionState::Pending) + } +} + +/// A `KausalSpec::StateGuard` on an [`ActionDef`] — the action fires only when +/// `field` holds `value`. `const`-constructible. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct StateGuard { + /// The field name guarded. + pub field: &'static str, + /// The value `field` must hold for the action to be eligible. + pub value: &'static str, +} + +/// DO arm — **static** action declaration (one per handler / transition), the +/// Perdurant sibling of [`crate::codegen_manifest::MethodSig`]. All fields are +/// `&'static`/`Copy` so a generated `const ACTIONS: &[ActionDef] = &[..]` +/// compiles — the action-axis manifest the consumer repos emit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ActionDef { + /// Event / handler name — the `has_function` method (e.g. `"action_confirm"`). + pub predicate: &'static str, + /// The acted-upon class (OGAR classid). + pub object_class: u32, + /// Where the action's work runs (native / jit / SurrealQL / elixir). + pub exec: ExecTarget, + /// Optional `KausalSpec::StateGuard`: fire only when `field == value`. + pub guard: Option, + /// RBAC role required to invoke this action (`None` = unguarded). + /// Checked against [`crate::auth::ActorContext`] at commit. + pub required_role: Option<&'static str>, + /// Fully-qualified parent-class action this overrides (OGAR `classid → + /// ClassView` inheritance), if any. `None` = a fresh action. + pub overrides: Option<&'static str>, +} + +impl ActionDef { + /// Whether this action overrides a parent-class action (OGAR inheritance). + /// Mirrors [`crate::codegen_manifest::MethodSig::is_override`]. + #[must_use] + pub const fn is_override(&self) -> bool { + self.overrides.is_some() + } +} + +/// One class's action manifest, keyed by classid — the action-axis sibling of +/// [`crate::codegen_manifest::ClassMethods`]. Generated downstream; the Core +/// provides the type + the (inheritance-aware) lookup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClassActions { + /// The OGAR classid this manifest belongs to. + pub classid: u32, + /// The class's own actions (a generated `const` table). + pub actions: &'static [ActionDef], +} + +/// Resolve a classid to its OWN action manifest within a generated `registry` +/// (zero-fallback: an unregistered classid resolves to no actions, never a +/// panic — mirrors [`crate::codegen_manifest::methods_for`]). +#[must_use] +pub fn actions_for(registry: &[ClassActions], classid: u32) -> &'static [ActionDef] { + registry + .iter() + .find(|entry| entry.classid == classid) + .map_or(&[], |entry| entry.actions) +} + +/// Compose a class's **effective** DO surface via OGAR inheritance: the +/// `parent` class's actions, then the `child`'s, with a child action of the +/// same `predicate` **overriding** the parent's. This is the `classid → +/// ClassView` inheritance applied to the action axis — a class inherits its +/// parents' actions and may supersede them, exactly as it inherits fields. +/// +/// Returns an owned `Vec` (resolution, not `const`): parent-only actions first +/// (in order), each replaced in place if the child redefines its `predicate`, +/// then the child's net-new actions appended. +#[must_use] +pub fn effective_actions(parent: &[ActionDef], child: &[ActionDef]) -> Vec { + // Precondition: predicates are unique WITHIN each class's action table (the + // harvest is the manifest — a duplicate predicate is a generator bug). The + // merge below is also defensively dedup'd so a stray duplicate never + // double-dispatches; the assert surfaces the generator bug in debug. + debug_assert!( + !has_duplicate_predicate(parent) && !has_duplicate_predicate(child), + "effective_actions: each ActionDef slice must have unique predicates per class" + ); + let mut out: Vec = Vec::with_capacity(parent.len() + child.len()); + // Parent actions, each overridden by a same-predicate child if present. + // Emit each predicate at most once (first occurrence within parent wins). + for p in parent { + if out.iter().any(|e| e.predicate == p.predicate) { + continue; // duplicate predicate already emitted + } + match child.iter().find(|c| c.predicate == p.predicate) { + Some(c) => out.push(*c), + None => out.push(*p), + } + } + // Child net-new actions (predicate not already emitted), deduped. + for c in child { + if out.iter().any(|e| e.predicate == c.predicate) { + continue; + } + out.push(*c); + } + out +} + +/// Whether `defs` contains two actions with the same `predicate` (a per-class +/// uniqueness violation — the harvest should never produce one). +fn has_duplicate_predicate(defs: &[ActionDef]) -> bool { + defs.iter() + .enumerate() + .any(|(i, a)| defs[..i].iter().any(|b| b.predicate == a.predicate)) +} + +/// DO arm — **dynamic** invocation (one per fire). Carries the lifecycle, the +/// S2.5 SoA cycle stamp, and the dedup/provenance keys. +/// +/// **`Clone`, NOT `Copy`** (deliberate): this is a one-shot lifecycle carrier +/// whose `commit` mutates `state`/`emitted_at_millis` in place. `Copy` would let +/// a caller commit a *copy* and silently lose the mutation on the original (and +/// mint duplicate-`idempotency_key` fires) — so duplication must be an explicit +/// `.clone()` at the call site. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionInvocation { + /// The acted-upon class (OGAR classid) — with `predicate`, identifies the + /// [`ActionDef`] this realizes. Equals `object_instance.classid()`. + pub object_class: u32, + /// The realized action's `has_function` name. + pub predicate: &'static str, + /// The specific instance acted on — the **full canonical [`NodeGuid`]**, not + /// a bare identity tail. It carries the `classid`, HHTL prefix, `family` + /// basin, and `identity`, so a consumer reconstructs the exact target with + /// no truncation or basin ambiguity. (A bare `u32` tail would panic the + /// 24-bit assert or alias two basins; the external-mutation egress must not + /// drop the key.) + pub object_instance: NodeGuid, + /// Lifecycle state. + pub state: ActionState, + /// SoA cycle-ownership stamp (S2.5) — the mailbox `current_cycle` that + /// precipitated this fire, so a dispatched action is tied to its cycle. + pub cycle: u32, + /// Idempotency key — dedups re-fires of the same logical action. + pub idempotency_key: u64, + /// Trace id for provenance. + pub trace_id: u64, + /// HLC emit stamp (set on commit); `None` while `Pending`. + pub emitted_at_millis: Option, +} + +impl ActionInvocation { + /// Construct a fresh `Pending` invocation. + /// + /// `object_instance` is the full canonical [`NodeGuid`] of the target; its + /// `classid()` must equal `object_class` (debug-asserted — the def-match + /// keys on `object_class`, the address on the GUID, and they must agree). + #[must_use] + pub fn pending( + object_class: u32, + predicate: &'static str, + object_instance: NodeGuid, + cycle: u32, + idempotency_key: u64, + trace_id: u64, + ) -> Self { + debug_assert_eq!( + object_instance.classid(), + object_class, + "object_instance GUID classid must match object_class" + ); + Self { + object_class, + predicate, + object_instance, + state: ActionState::Pending, + cycle, + idempotency_key, + trace_id, + emitted_at_millis: None, + } + } + + /// Adjudicate a `Pending` action against its `ActionDef` (match), RBAC, the + /// state guard, and the MUL impact gate, in that order. Returns the + /// resulting [`ActionState`]. + /// + /// `guard_field_value` is the current value of `def.guard.field` on the + /// target instance, supplied by the caller (the Core holds no object state — + /// `I-VSA-IDENTITIES`); `None` when unknown. It is consulted ONLY when + /// `def.guard` is `Some`. + /// + /// Order and outcomes: + /// - `def` does not identify THIS invocation (`object_class`/`predicate` + /// mismatch) → `Failed` — authorization is NEVER applied against an + /// unrelated definition's `required_role`. + /// - unauthorized actor (lacks [`ActionDef::required_role`], not admin) → `Failed` + /// - state guard present and unsatisfied (`guard_field_value != Some(value)`) + /// → `Cancelled` — the action is not eligible in the current state. + /// - MUL `Flow` → `Committed` (dispatched; `emitted_at_millis` stamped) + /// - MUL `Hold` → stays `Pending` (escalate / re-assess next cycle) + /// - MUL `Block` → `Cancelled` + /// + /// Terminal states are sticky (a committed/failed/cancelled action is not + /// re-adjudicated). The `def`-match is checked FIRST, before RBAC/guard/MUL. + pub fn commit( + &mut self, + def: &ActionDef, + actor: &ActorContext, + impact: &GateDecision, + guard_field_value: Option<&str>, + now_millis: u64, + ) -> ActionState { + if self.state.is_terminal() { + return self.state; // sticky + } + // The `def` MUST identify THIS invocation — otherwise RBAC/guard/MUL + // would adjudicate against an unrelated action's policy (P1). + if def.object_class != self.object_class || def.predicate != self.predicate { + self.state = ActionState::Failed; + return self.state; + } + // RBAC — authority before eligibility/impact. + if let Some(role) = def.required_role { + let authorized = actor.is_admin() || actor.roles.iter().any(|r| r == role); + if !authorized { + self.state = ActionState::Failed; + return self.state; + } + } + // State guard — fire only when `field == value` (P2). An unsatisfied (or + // unknown) guarded state refuses the fire; a fresh invocation runs when + // the instance re-enters the eligible state. + if let Some(g) = def.guard { + if guard_field_value != Some(g.value) { + self.state = ActionState::Cancelled; + return self.state; + } + } + // MUL impact assessment. + self.state = match impact { + GateDecision::Flow => { + self.emitted_at_millis = Some(now_millis); + ActionState::Committed + } + GateDecision::Hold { .. } => ActionState::Pending, + GateDecision::Block { .. } => ActionState::Cancelled, + }; + self.state + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `const`-constructibility — the exact shape a generated + /// `const ACTIONS: &[ActionDef]` emits (mirrors the `MethodSig` guard). + const SALE_ORDER_ACTIONS: &[ActionDef] = &[ + ActionDef { + predicate: "action_confirm", + object_class: 0x0A1E_0001, + exec: ExecTarget::SurrealQl, + guard: Some(StateGuard { + field: "state", + value: "draft", + }), + required_role: Some("sales_manager"), + overrides: None, + }, + ActionDef { + predicate: "action_cancel", + object_class: 0x0A1E_0001, + exec: ExecTarget::SurrealQl, + guard: None, + required_role: None, + overrides: None, + }, + ]; + + const REGISTRY: &[ClassActions] = &[ClassActions { + classid: 0x0A1E_0001, + actions: SALE_ORDER_ACTIONS, + }]; + + #[test] + fn const_action_manifest_constructs_and_resolves() { + assert_eq!(actions_for(REGISTRY, 0x0A1E_0001).len(), 2); + assert!( + actions_for(REGISTRY, 0xDEAD).is_empty(), + "unregistered classid → no actions (zero-fallback)" + ); + assert_eq!(SALE_ORDER_ACTIONS[0].exec, ExecTarget::SurrealQl); + } + + #[test] + fn ogar_inheritance_child_overrides_parent_by_predicate() { + let parent = &[ + ActionDef { + predicate: "action_confirm", + object_class: 0x01, + exec: ExecTarget::Native, + guard: None, + required_role: None, + overrides: None, + }, + ActionDef { + predicate: "message_post", + object_class: 0x01, + exec: ExecTarget::Native, + guard: None, + required_role: None, + overrides: None, + }, + ]; + let child = &[ + // overrides the parent's action_confirm + ActionDef { + predicate: "action_confirm", + object_class: 0x02, + exec: ExecTarget::SurrealQl, + guard: None, + required_role: Some("sales_manager"), + overrides: Some("parent::action_confirm"), + }, + // net-new + ActionDef { + predicate: "action_done", + object_class: 0x02, + exec: ExecTarget::SurrealQl, + guard: None, + required_role: None, + overrides: None, + }, + ]; + let eff = effective_actions(parent, child); + assert_eq!(eff.len(), 3, "confirm(overridden) + message_post + done"); + let confirm = eff + .iter() + .find(|a| a.predicate == "action_confirm") + .unwrap(); + assert_eq!( + confirm.object_class, 0x02, + "child's action_confirm overrides the parent's" + ); + assert_eq!(confirm.required_role, Some("sales_manager")); + // inherited unchanged + assert!(eff + .iter() + .any(|a| a.predicate == "message_post" && a.object_class == 0x01)); + // net-new appended + assert!(eff.iter().any(|a| a.predicate == "action_done")); + } + + fn actor_with(roles: &[&str]) -> ActorContext { + // TenantId = u64 (crate::sla); 0 = default tenant. + ActorContext::new( + "u1".to_string(), + 0, + roles.iter().map(|s| s.to_string()).collect(), + ) + } + + /// A target instance GUID whose classid matches the sale_order actions + /// (0x0A1E_0001), default basin, `identity` discriminating. + fn inst(identity: u32) -> NodeGuid { + NodeGuid::new(0x0A1E_0001, 0, 0, 0, 0, identity) + } + + #[test] + fn commit_requires_rbac_then_mul_flow() { + let def = &SALE_ORDER_ACTIONS[0]; // required_role = "sales_manager" + + // authorized + guard satisfied (state==draft) + Flow → Committed, stamped. + let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(7), 3, 1, 1); + assert_eq!( + inv.commit( + def, + &actor_with(&["sales_manager"]), + &GateDecision::Flow, + Some("draft"), + 1000 + ), + ActionState::Committed + ); + assert_eq!(inv.emitted_at_millis, Some(1000)); + assert_eq!(inv.cycle, 3, "cycle stamp preserved"); + + // committed is sticky — re-adjudication is a no-op. + assert_eq!( + inv.commit( + def, + &actor_with(&["sales_manager"]), + &GateDecision::Block { + reason: "x".to_string() + }, + Some("draft"), + 2000 + ), + ActionState::Committed + ); + } + + #[test] + fn commit_unauthorized_fails_before_impact() { + let def = &SALE_ORDER_ACTIONS[0]; + let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(7), 3, 1, 1); + // wrong role, even with a Flow gate + satisfied guard → Failed (RBAC first). + assert_eq!( + inv.commit( + def, + &actor_with(&["viewer"]), + &GateDecision::Flow, + Some("draft"), + 1000 + ), + ActionState::Failed + ); + assert_eq!(inv.emitted_at_millis, None, "failed action never emitted"); + } + + #[test] + fn mul_hold_keeps_pending_block_cancels() { + let def = &SALE_ORDER_ACTIONS[1]; // no required_role + let any = actor_with(&[]); + + // action_cancel has no guard → guard_field_value is ignored (None). + let mut held = ActionInvocation::pending(0x0A1E_0001, "action_cancel", inst(7), 3, 1, 1); + assert_eq!( + held.commit( + def, + &any, + &GateDecision::Hold { + reason: "low confidence".to_string() + }, + None, + 1000 + ), + ActionState::Pending, + "Hold escalates — stays Pending for re-assessment" + ); + + let mut blocked = ActionInvocation::pending(0x0A1E_0001, "action_cancel", inst(7), 3, 1, 1); + assert_eq!( + blocked.commit( + def, + &any, + &GateDecision::Block { + reason: "unsound impact".to_string() + }, + None, + 1000 + ), + ActionState::Cancelled + ); + } + + /// P1 (codex #538): `commit` must reject a `def` that does not identify this + /// invocation BEFORE applying RBAC — else passing the unguarded, no-role + /// `action_cancel` def to an `action_confirm` invocation would reach + /// `Committed` under `Flow` without the confirm role. + #[test] + fn commit_rejects_mismatched_def() { + let confirm_inv_with_cancel_def = || { + let mut inv = + ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(7), 3, 1, 1); + // SALE_ORDER_ACTIONS[1] is action_cancel (no required_role, no guard). + let state = inv.commit( + &SALE_ORDER_ACTIONS[1], + &actor_with(&[]), + &GateDecision::Flow, + None, + 1000, + ); + (inv, state) + }; + let (inv, state) = confirm_inv_with_cancel_def(); + assert_eq!( + state, + ActionState::Failed, + "a def whose predicate/object_class mismatch the invocation must NOT authorize" + ); + assert_eq!(inv.emitted_at_millis, None, "mismatched def never emits"); + } + + /// P2 (codex #538): a guarded action (`state == draft`) must NOT commit when + /// the target instance is in a non-eligible state, even with role + `Flow`. + #[test] + fn guarded_action_refused_in_wrong_state() { + let def = &SALE_ORDER_ACTIONS[0]; // guard: state == "draft" + let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(7), 3, 1, 1); + let state = inv.commit( + def, + &actor_with(&["sales_manager"]), + &GateDecision::Flow, + Some("sent"), // wrong state + 1000, + ); + assert_eq!( + state, + ActionState::Cancelled, + "guarded action in a non-eligible state is refused, not committed" + ); + assert_eq!(inv.emitted_at_millis, None, "refused action never emits"); + + // unknown state (None) with a guard present is also refused. + let mut inv2 = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(7), 3, 1, 1); + assert_eq!( + inv2.commit( + def, + &actor_with(&["sales_manager"]), + &GateDecision::Flow, + None, + 1000 + ), + ActionState::Cancelled, + "unknown guarded state is refused" + ); + } +} diff --git a/crates/lance-graph-contract/src/kanban.rs b/crates/lance-graph-contract/src/kanban.rs index 38a1b33a..cd3eb46c 100644 --- a/crates/lance-graph-contract/src/kanban.rs +++ b/crates/lance-graph-contract/src/kanban.rs @@ -128,6 +128,27 @@ pub struct KanbanMove { pub exec: ExecTarget, } +impl KanbanMove { + /// The SoA cycle-ownership stamp (S2.5) — the mailbox `current_cycle` at + /// which this lifecycle step was emitted. + /// + /// Both real paths that record a move ([`crate::scheduler`] and the + /// `cognitive-shader-driver` `MailboxSoaOwner` impl — the mailbox writing its + /// own lifecycle step in place, NOT an inter-mailbox emission per the #477 + /// three-tier model) stamp `witness_chain_position = current_cycle` (the + /// documented "monotonic cycle + /// stamp stands in for the chain index until the A3 witness-arc column lands" + /// convention). This accessor names that intent so the planner and a + /// `ExecTarget::SurrealQl` read-as-of can be cycle-aware off ONE source of + /// truth without growing the ≤16 B airgap baton. When the A3 witness-arc + /// column lands and `witness_chain_position` becomes a distinct chain index, + /// this accessor moves to its own field (an A3-era change, version-gated). + #[inline] + pub fn cycle(&self) -> u32 { + self.witness_chain_position + } +} + /// The execution backend a [`KanbanMove`] is dispatched to — the planner's /// JIT-adjacent **strategy target**. Distinct from the planner's 16 composable /// *planning* strategies: this names *where the precipitated plan runs*. diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index 3ec51157..b1539df7 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -38,6 +38,7 @@ pub mod cognition; pub mod transaction; pub mod a2a_blackboard; +pub mod action; pub mod atoms; pub mod auth; pub mod callcenter; diff --git a/crates/lance-graph-contract/src/scheduler.rs b/crates/lance-graph-contract/src/scheduler.rs index 1bb55b14..6200066e 100644 --- a/crates/lance-graph-contract/src/scheduler.rs +++ b/crates/lance-graph-contract/src/scheduler.rs @@ -94,7 +94,8 @@ impl VersionScheduler for NextPhaseScheduler { from, to, // Structural witness position (R4): the monotonic cycle stamp stands in - // for the chain index until the A3 `witness_arc` column lands. + // for the chain index until the A3 `witness_arc` column lands. Read it as + // the SoA cycle-ownership stamp via `KanbanMove::cycle()` (S2.5). witness_chain_position: view.current_cycle(), libet_offset_us, exec, diff --git a/crates/lance-graph-contract/src/soa_view.rs b/crates/lance-graph-contract/src/soa_view.rs index 51198c1d..b488575d 100644 --- a/crates/lance-graph-contract/src/soa_view.rs +++ b/crates/lance-graph-contract/src/soa_view.rs @@ -192,7 +192,7 @@ mod tests { mailbox: self.id, from, to, - witness_chain_position: 0, + witness_chain_position: self.cycle, libet_offset_us: if to == KanbanColumn::CognitiveWork { -550_000 } else { diff --git a/docs/OGAR_CONSUMER_API.md b/docs/OGAR_CONSUMER_API.md new file mode 100644 index 00000000..e7ffc16a --- /dev/null +++ b/docs/OGAR_CONSUMER_API.md @@ -0,0 +1,235 @@ +# OGAR API — the consumer contract (THINK + DO) + +> **Audience:** the consumer repos that transcode an Active-Record domain onto +> the OGAR Core — `odoo-rs`, `openproject-nexgen-rs`, `woa-rs`, `tesseract-rs` +> (and any future `customer-` crate). +> **Source of truth:** the types in `lance-graph-contract`. This doc is the +> map; the `///` docs on each type are authoritative. +> **Doctrine:** OGAR Core-First — a generated adapter is only as clean as the +> Core it targets. Emit **thin, `classid`-keyed adapters that ASSUME the Core**; +> never a parallel object model, never an adapter that carries its own state. + +--- + +## 0. The one-paragraph model + +An Active Record (an Odoo `sale.order`, an OpenProject work-package, a WoA work +order, a Tesseract C++ class) is **fields + methods**. OGAR splits it along +DOLCE: **fields → Endurant → THINK state** (SoA value tenants), **methods → +Perdurant → DO actions** (`ActionInvocation` via `UnifiedStep`), **inheritance → +`classid → ClassView`**. The SPO harvest (`has_function` / `reads_field` / +`inherits_from` / `target` / `validation_kind`) **is** the manifest the consumer +generates from; the consumer never hand-authors the object model. + +``` + your domain source ──harvest──► SPO {s,p,o,f,c} ──generate──► const ClassMethods + ClassActions + │ (has_function, │ (classid-keyed tables) + │ reads_field, …) ▼ + └────────────────────────────────────────────► the OGAR Core (lance-graph-contract) + classid → ClassView → ValueTenant (THINK) + classid → ClassActions → ActionDef (DO) + │ + ActionInvocation::commit (RBAC + MUL gate) + │ + UnifiedStep → ExecTarget (Native/Jit/SurrealQl/Elixir) +``` + +--- + +## 1. THINK arm — state, identity, methods (already converged + merged) + +| Concern | OGAR Core type (`lance-graph-contract`) | Sourced from (harvest) | +|---|---|---| +| **Identity** | `canonical_node::NodeGuid` (`classid` u32 + GUID tail) | the class itself; `classid` bound OGAR-side, never minted by the manifest | +| **State** (Endurant) | `canonical_node::{ValueTenant, ValueSchema, VALUE_TENANTS}`; presence delta = `class_view::FieldMask` | `reads_field` | +| **Method signatures** | `codegen_manifest::{MethodSig, ClassMethods}` + `methods_for(registry, classid)` | `has_function` | +| **Composition / inheritance** | `class_view::ClassView` (`fields()` / `inherit()` / `value_schema()`); `FieldMask::inherit` | `inherits_from` / `virtually_overrides` | +| **Relations** | `canonical_node::EdgeBlock` (12 in-family + 4 out-of-family) | `target` / `inverse_name` | + +**Rule:** the `MethodSig`/`ClassMethods` tables and the `ValueSchema` presets are +generated **in your crate** as `const … : &[…]` (every field is `&'static`, so +they compile as `const`). The Core owns the *type + the lookup*; you own the +*data*. `methods_for` is zero-fallback — an unregistered `classid` resolves to an +empty slice, never a panic. + +--- + +## 2. DO arm — actions (`lance-graph-contract::action`) + +The Perdurant complement. Generated from `has_function`, gated at commit by RBAC ++ MUL, routed via `UnifiedStep`. + +### 2.1 `ActionDef` — static action declaration (`const`-constructible) + +```rust +pub struct ActionDef { + pub predicate: &'static str, // the has_function method, e.g. "action_confirm" + pub object_class: u32, // OGAR classid + pub exec: ExecTarget, // Native | Jit | SurrealQl | Elixir + pub guard: Option, // KausalSpec: fire only when field == value + pub required_role: Option<&'static str>, // RBAC role required (None = unguarded) + pub overrides: Option<&'static str>, // parent-class action this supersedes (inheritance) +} +``` + +Generate one `const ACTIONS: &[ActionDef]` per class, register as +`ClassActions { classid, actions }`, resolve with +`actions_for(registry, classid)` (zero-fallback, the action-axis sibling of +`methods_for`). + +### 2.2 OGAR inheritance — `effective_actions(parent, child)` + +A class's DO surface is **its parents' actions + its own, child overrides parent +by `predicate`**. This is `classid → ClassView` inheritance on the action axis — +the same mechanism the field-set uses. You do **not** flatten a parent's actions +into the child; you compose them: + +```rust +let eff = effective_actions(parent_class_actions, child_class_actions); +// parent actions, with any same-`predicate` child action substituted, then child net-new appended. +``` + +### 2.3 `ActionInvocation` — dynamic fire (one per call) + +```rust +pub struct ActionInvocation { + pub object_class: u32, pub predicate: &'static str, // → the ActionDef realized + pub object_instance: u32, // the GUID identity tail acted on + pub state: ActionState, // Pending → Committed | Failed | Cancelled + pub cycle: u32, // S2.5 SoA cycle-ownership stamp + pub idempotency_key: u64, pub trace_id: u64, + pub emitted_at_millis: Option, // HLC stamp, set on commit +} +``` + +### 2.4 The commit gate — RBAC then MUL (the egress) + +A DO action mutates an external domain system, so it does **not** fire freely. +`ActionInvocation::commit` is the "commit to the external consumer after the +cycle decides the result sound" egress: + +```rust +// def actor impact guard_field_value now +let outcome = inv.commit(&def, &actor, &impact, Some(current_state_value), now_millis); +``` + +Adjudication order (each step can short-circuit): +- **`def`-match first**: `def.object_class`/`predicate` MUST identify this + invocation, else `ActionState::Failed` — RBAC/guard/MUL are never applied + against an unrelated definition's policy. +- **RBAC** (`auth::ActorContext`): actor must hold `def.required_role` (or be + admin) → else `Failed`. +- **State guard** (`def.guard`): if present, `guard_field_value` must equal + `guard.value` (the caller supplies the target instance's current field value — + the Core holds no object state) → else `Cancelled` (not eligible in this + state). `guard_field_value` is ignored when `def.guard` is `None`. +- **MUL impact** (`mul::GateDecision`): `Flow → Committed` (HLC-stamped, + dispatched); `Hold → ` stays `Pending` (escalate / re-assess next cycle); + `Block → Cancelled`. +- Terminal states are **sticky** (a committed/failed/cancelled action is never + re-adjudicated). A `Hold→Pending` action CAN be re-`commit`ted (re-assessment) + — **idempotency/replay dedup is the caller's (dispatch-layer) responsibility**, + keyed on `(object_instance, idempotency_key)`; `commit` is policy adjudication + only, it does not itself dedup re-fires. + +### 2.5 Dispatch — `UnifiedStep`, never a per-crate endpoint + +A committed action runs via `orchestration::{UnifiedStep, OrchestrationBridge}` +routed by `step_type` prefix to a `StepDomain`, at the `ExecTarget` the +`ActionDef` names. `ExecTarget::SurrealQl` lowers the action to SurrealQL and +runs it in the substrate (the AR-shaped API surface). **Do not** add a +`/v1/` REST endpoint — that is the System-1 trap; extend the canonical +bridge. + +--- + +## 3. Per-consumer wiring (the recipe) + +For each consumer crate: + +1. **Harvest** your domain into SPO `{s,p,o,f,c}` (`has_function`, `reads_field`, + `inherits_from`, `target`, `validation_kind`). For Rails/Python ORMs use the + `ruff`/OGAR producer bridges; for C++ use `ruff_cpp_spo`. +2. **Generate** `const` tables: `ClassMethods` (from `has_function`), + `ClassActions` (from `has_function` → `ActionDef`, with `required_role` from + your RBAC map and `exec` = your target, typically `SurrealQl`), + `ValueSchema`/`FieldMask` (from `reads_field`), inheritance edges (from + `inherits_from`). +3. **Bind classids** OGAR-side (a `to_node_row(classid, …)`-style entry; the + manifest never mints a classid). +4. **Body adapters** — thin, `classid`-keyed, ASSUME the Core. A leaf method + reads value tenants / edges, transforms, writes back through the gated path. + It carries **no state of its own**. +5. **Route intrusive methods to hand-port** — a method that mutates a child + collection from transient state (`_apply_grid`, `@api.onchange` buffers, + matrix configurators) does **not** fit the adapter mold; raw hand-port it. + Forcing it in is the Adapter-State-Leak the doctrine forbids. +6. **Commit through the gate** — never write to the external system directly; + build an `ActionInvocation`, `commit` it with the actor + the MUL gate, and + dispatch the `Committed` ones via `UnifiedStep`. + +--- + +## 4. Iron rules for consumers + +1. **Thin adapters that ASSUME the Core.** Identity = `classid`; state = value + tenants; relations = `EdgeBlock`; composition = `classid → ClassView`; + invocation = `UnifiedStep`. An adapter that needs state the SoA can't carry + is a **Core gap → file a `ClassView` extension**, never an adapter hack. +2. **The harvest IS the manifest.** Don't hand-author the object model; generate + the `const` tables from SPO. Don't let an adapter keep its own `@api.depends` + table (that reinvents the ORM) — recompute dispatch is a `ClassView` + capability (`compute_dag`, in progress), not adapter state. +3. **No parallel object model.** One OGAR Core; consumers are classid adapters + into it. +4. **No model identifier** in any committed artifact; **no PII labels** leaking + from the domain (leaf-rename at the adapter). +5. **Egress only through the commit gate** (RBAC + MUL + state guard) and `UnifiedStep`. +6. **`required_role` and `exec` are consumer-private policy, NOT portable.** They + are not in the `has_function` harvest — each consumer supplies its own RBAC + map + exec target. The contract guarantees the action *shape*, never that two + consumers agree on a role/exec for the "same" `(classid, predicate)`. +7. **Normalize predicates per a documented rule.** The commit def-match is exact + `(object_class, predicate)` string equality; `(classid, predicate)` identity + is **consumer-local**. Cross-consumer action equality is NOT a contract + guarantee — two harvests of `action_confirm` vs `actionConfirm` are distinct + actions. Pick one normalization (snake_case) at harvest time. + +--- + +## 5. Status (what's CODED vs in-progress) + +- **CODED:** `NodeGuid`/`classid`/`EdgeBlock`, `ClassView` trait + `RegistryClassView`, + `ValueTenant`/`ValueSchema`, `codegen_manifest::{MethodSig, ClassMethods, methods_for}`, + `orchestration::{UnifiedStep, OrchestrationBridge, StepDomain}`, and the DO arm + `action::{ActionDef, ActionInvocation, ClassActions, actions_for, effective_actions}` + with the RBAC+MUL commit gate (this PR). +- **In progress / named gaps:** the D-CLS field enumeration that auto-populates a + `ClassView` field-set from a harvested model (lance-graph #534 landed the + resolution keystone); the `ClassView::{compute_dag, constraints}` extension for + computed-field recompute + validation dispatch; `PROBE-OGAR-ADAPTER-UNICHARSET` + byte-parity (the licence to scale the adapter approach). + +--- + +## 6. Minimal end-to-end (the AR→DO existence proof) + +```rust +use lance_graph_contract::action::*; +use lance_graph_contract::kanban::ExecTarget; + +// generated from sale_order's has_function row: +const SALE_ORDER: &[ActionDef] = &[ActionDef { + predicate: "action_confirm", object_class: 0x0A1E_0001, + exec: ExecTarget::SurrealQl, guard: Some(StateGuard { field: "state", value: "draft" }), + required_role: Some("sales_manager"), overrides: None, +}]; + +// object_instance is the FULL NodeGuid (classid must match), not a bare id. +let target = NodeGuid::new(0x0A1E_0001, 0, 0, 0, /*family*/ 0, /*identity*/ 42); +let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", target, /*cycle*/ 7, 1, 1); +// guard is state=="draft"; supply the instance's current state value. +let outcome = inv.commit(&SALE_ORDER[0], &actor /* holds sales_manager */, &GateDecision::Flow, Some("draft"), now); +// outcome == ActionState::Committed → dispatch via UnifiedStep at ExecTarget::SurrealQl. +// (wrong def, missing role, state != "draft", or a Hold/Block gate would NOT commit.) +``` diff --git a/docs/STACK_SCAFFOLD.md b/docs/STACK_SCAFFOLD.md new file mode 100644 index 00000000..82a1ea9b --- /dev/null +++ b/docs/STACK_SCAFFOLD.md @@ -0,0 +1,113 @@ +# Stack scaffold — surrealdb + ractor + ndarray (so a session doesn't have to guess) + +> **Purpose:** the known-good wiring for the three-pillar runtime — **ndarray** +> (SIMD/HPC substrate), **ractor** (actor runtime / mailbox owner), **surrealdb** +> (KV-lance storage + SurrealQL AR-API surface). Copy the fragments below; do not +> re-derive the coordinates each session. +> **P0 (CLAUDE.md):** every crate with an `AdaWorldAPI/` fork is wired via +> the **fork** (`path`/`git`), NEVER crates.io. All three here are forks. + +## Status (honest, 2026-06-18) + +| Pillar | Fork | Local path | Ready? | +|---|---|---|---| +| **ndarray** | `AdaWorldAPI/ndarray` | `/home/user/ndarray` | ✅ ready (HPC, 880+ tests) | +| **ractor** | `AdaWorldAPI/ractor` | `/home/user/ractor` | ✅ ready | +| **surrealdb** | `AdaWorldAPI/surrealdb` | `/home/user/surrealdb` | ⚠️ **KV-lance: backend MODULE implemented, not yet feature-wired.** The Lance KV backend is real code at `crates/core/src/kvs/lance/` (`mod`/`schema`/`tx_buffer`/`timeline`/`background_optimizer`/`tests` — the 19-method `Transactable` scaffold), NOT a sketch. But it is **not yet exposed as a storage feature** (no `kv-lance` in `crates/core/Cargo.toml`, not `mod`'d into `kvs/mod.rs`, no `lance` dep wired) and the `TODO(lance-integration)` Lance-API call sites remain (the `.claude/lance-backend/DAY_BY_DAY.md` 12-day item). Use `storage-mem` until the feature + integration land; then switch the `surrealdb` feature line to `kv-lance`. | + +**Footprint (resolved-crate proxy):** lance-graph `Cargo.lock` ≈ **889**; +surrealdb (all backends) ≈ **1148**. Both share the lance+arrow base; surreal's +marginal cost is the SurrealQL engine + multi-protocol server (~260 crates), +NOT the storage backend. Dropping `storage-rocksdb` removes the RocksDB C++ +native build (compile-time + tens of MB) but not the engine. Prefer pulling the +surreal **KV-lance + AR-API surface** selectively (where `ExecTarget::SurrealQl` +is wanted), not the full engine — lance-graph already covers storage+query+TS +(lance versioning = time-series, datafusion = query). + +## `Cargo.toml` (reference — paths assume siblings under the same parent dir) + +```toml +[package] +name = "ada-stack-app" +version = "0.1.0" +edition = "2021" +rust-version = "1.94" + +[dependencies] +# ── ndarray: SIMD / HPC substrate (fork; default-features off → pick HPC) ── +ndarray = { path = "../ndarray", default-features = false, features = ["std"] } + +# ── ractor: actor runtime / mailbox owner (fork) ── +ractor = { path = "../ractor/ractor", default-features = false, features = ["tokio_runtime"] } +# ractor_cluster = { path = "../ractor/ractor_cluster" } # only if distributing + +# ── lance-graph spine + the contract (fork; the OGAR Core + DO arm live here) ── +lance-graph-contract = { path = "../lance-graph/crates/lance-graph-contract" } +# lance-graph = { path = "../lance-graph/crates/lance-graph" } # full spine (heavy: datafusion) + +# ── surrealdb: storage + SurrealQL AR-API (fork) ── +# PENDING: replace `storage-mem` with `kv-lance` once the fork's lance-backend +# (surrealdb/.claude/lance-backend) lands. Keep default-features = false to avoid +# pulling rocksdb/tikv/scripting unless needed. +surrealdb = { path = "../surrealdb/surrealdb", default-features = false, features = ["kv-mem"] } + +[dependencies.tokio] +version = "1" +features = ["rt-multi-thread", "macros"] + +# Fork pins also expressible as git, e.g.: +# ndarray = { git = "https://github.com/AdaWorldAPI/ndarray", branch = "main" } +# Use path when the sibling clone exists (this workspace); git for CI without it. +``` + +> Verify the exact surreal crate path/feature names against +> `/home/user/surrealdb/Cargo.toml` before relying on this — the surreal +> workspace lays its public crate under `surrealdb/` and gates storage via +> `surrealdb-server` features (`storage-mem` → `kv-mem` mapping). The line above +> is the intended shape; it is NOT yet build-verified (kv-lance pending). + +## `Dockerfile` (reference — Rust 1.94, mold linker, no debuginfo for fast link) + +```dockerfile +# syntax=docker/dockerfile:1 +FROM rust:1.94-bookworm AS build + +# mold avoids the rust-lld SIGBUS link-cliff seen in this workspace. +RUN apt-get update && apt-get install -y --no-install-recommends \ + mold clang cmake pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +ENV CARGO_PROFILE_DEV_DEBUG=0 \ + RUSTFLAGS="-C link-arg=-fuse-ld=mold -C target-cpu=x86-64-v3" +# AVX-512 stack (ndarray HPC, x86-64-v4): set target-cpu=x86-64-v4 on capable silicon. + +WORKDIR /build +# Sibling forks must be present in the build context (copy or git-clone them): +# /build/ndarray /build/ractor /build/surrealdb /build/lance-graph /build/ada-stack-app +COPY . . +WORKDIR /build/ada-stack-app +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/build/ada-stack-app/target \ + cargo build --release && cp target/release/ada-stack-app /ada-stack-app + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /ada-stack-app /usr/local/bin/ada-stack-app +ENTRYPOINT ["ada-stack-app"] +``` + +Notes: +- **No RocksDB** in this image — `surrealdb` is `default-features = false` so no + C++ rocksdb/speedb build. When `kv-lance` lands it adds no native toolchain + beyond what lance already needs. +- **mold + `CARGO_PROFILE_DEV_DEBUG=0`** mirror the link-cliff workaround this + workspace uses for the contract/driver crates. +- The build context must contain the sibling fork checkouts (path deps); for a + CI image without them, switch the `Cargo.toml` lines to `git =` fork pins. + +## What this deliberately is NOT + +- NOT a lance-graph workspace member (it would bloat the spine's build with the + surreal engine + datafusion together). It is a **copy-paste reference**. +- NOT build-verified for the surreal pillar until `kv-lance` lands — the ndarray + + ractor + lance-graph-contract lines are the ready core.