diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 25f042e..70df5fa 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -17,3 +17,7 @@ echo "[devcontainer] Priming cargo registry cache (optional)..." cargo fetch || true echo "[devcontainer] Done. Run 'cargo test -p rmg-core' or 'make ci-local' to validate." +if [ -f Makefile ]; then + echo "[devcontainer] Installing git hooks (make hooks)" + make hooks || true +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 9060dc2..85713a2 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,11 +1,6 @@ #!/usr/bin/env bash set -euo pipefail - -if [[ "${SKIP_HOOKS:-}" == 1 ]]; then - exit 0 -fi - # 1) PRNG coupling guard (existing logic) PRNG_FILE="crates/rmg-core/src/math/prng.rs" if git diff --cached --name-only | grep -qx "$PRNG_FILE"; then @@ -54,13 +49,13 @@ fi _auto_fmt="${ECHO_AUTO_FMT:-1}" case "${_auto_fmt}" in 1|true|TRUE|yes|YES|on|ON) - echo "pre-commit: ECHO_AUTO_FMT=${_auto_fmt} → running cargo fmt (auto-fix)" - cargo fmt --all || { echo "pre-commit: cargo fmt failed" >&2; exit 1; } - # Re-stage only staged files that were reformatted - if STAGED=$(git diff --cached --name-only); then - if [[ -n "$STAGED" ]]; then - echo "$STAGED" | xargs -r git add -- - fi + echo "pre-commit: ECHO_AUTO_FMT=${_auto_fmt} → checking format" + if ! cargo fmt --all -- --check; then + echo "pre-commit: running cargo fmt to apply changes" >&2 + cargo fmt --all || { echo "pre-commit: cargo fmt failed" >&2; exit 1; } + echo "pre-commit: rustfmt updated files. Aborting commit to preserve index integrity (partial staging safe)." >&2 + echo "Hint: review changes, restage (e.g., 'git add -p' or 'git add -A'), then commit again." >&2 + exit 1 fi ;; 0|false|FALSE|no|NO|off|OFF) @@ -72,12 +67,12 @@ case "${_auto_fmt}" in ;; esac -# 4) Docs guard (scaled): only require docs when core public API changed +# 4) Docs guard: require docs updates on any Rust changes STAGED=$(git diff --cached --name-only) -CORE_API_CHANGED=$(echo "$STAGED" | grep -E '^crates/rmg-core/src/.*\.rs$' | grep -v '/tests/' || true) -if [[ -n "$CORE_API_CHANGED" ]]; then - echo "$STAGED" | grep -Fx 'docs/execution-plan.md' >/dev/null || { echo 'pre-commit: docs/execution-plan.md must be updated when core API changes.' >&2; exit 1; } - echo "$STAGED" | grep -Fx 'docs/decision-log.md' >/dev/null || { echo 'pre-commit: docs/decision-log.md must be updated when core API changes.' >&2; exit 1; } +RUST_CHANGED=$(echo "$STAGED" | grep -E '\\.rs$' || true) +if [[ -n "$RUST_CHANGED" ]]; then + echo "$STAGED" | grep -Fx 'docs/execution-plan.md' >/dev/null || { echo 'pre-commit: docs/execution-plan.md must be updated when Rust files change.' >&2; exit 1; } + echo "$STAGED" | grep -Fx 'docs/decision-log.md' >/dev/null || { echo 'pre-commit: docs/decision-log.md must be updated when Rust files change.' >&2; exit 1; } fi # 5) Lockfile guard: ensure lockfile version is v3 (current cargo format) diff --git a/.githooks/pre-push b/.githooks/pre-push index efb35bc..2ea0f1f 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -PINNED="${PINNED:-1.71.1}" -# MSRV floor for library checks (override with MSRV env) -MSRV="${MSRV:-1.71.1}" +# Resolve the pinned toolchain from rust-toolchain.toml, fallback to explicit env or a sane default +PINNED_FROM_FILE=$(awk -F '"' '/^channel/ {print $2}' rust-toolchain.toml 2>/dev/null || echo "") +PINNED="${PINNED:-${PINNED_FROM_FILE:-1.90.0}}" for cmd in cargo rustup rg; do if ! command -v "$cmd" >/dev/null 2>&1; then @@ -13,64 +13,46 @@ done echo "🐰 BunBun 🐇" -if [[ "${SKIP_HOOKS:-}" == 1 ]]; then - exit 0 -fi -echo "[pre-push] fmt (default toolchain)" +echo "[pre-push] fmt (stable)" cargo fmt --all -- --check -echo "[pre-push] clippy (workspace, default toolchain)" +echo "[pre-push] clippy (workspace, stable)" cargo clippy --all-targets -- -D warnings -D missing_docs -echo "[pre-push] tests (workspace, default toolchain)" +echo "[pre-push] tests (workspace, stable)" cargo test --workspace -echo "[pre-push] Testing against MSRV ${MSRV} (core libraries)" -# If any participating crate declares a rust-version greater than MSRV, skip MSRV checks entirely. -CORE_RV=$(awk -F '"' '/^rust-version/ {print $2}' crates/rmg-core/Cargo.toml 2>/dev/null || echo "") -GEOM_RV=$(awk -F '"' '/^rust-version/ {print $2}' crates/rmg-geom/Cargo.toml 2>/dev/null || echo "") -if { [[ -n "$CORE_RV" ]] && printf '%s\n%s\n' "$MSRV" "$CORE_RV" | sort -V | tail -n1 | grep -qx "$CORE_RV" && [[ "$CORE_RV" != "$MSRV" ]]; } \ - || { [[ -n "$GEOM_RV" ]] && printf '%s\n%s\n' "$MSRV" "$GEOM_RV" | sort -V | tail -n1 | grep -qx "$GEOM_RV" && [[ "$GEOM_RV" != "$MSRV" ]]; }; then - echo "[pre-push] Skipping MSRV block: one or more crates declare rust-version > ${MSRV} (core=${CORE_RV:-unset}, geom=${GEOM_RV:-unset})" -else - if ! rustup run "$MSRV" cargo -V >/dev/null 2>&1; then - echo "[pre-push] MSRV toolchain ${MSRV} not installed. Install via: rustup toolchain install ${MSRV}" >&2 - exit 1 - fi - # Only run MSRV tests for crates that declare rust-version <= MSRV; skip otherwise. - msrv_ok() { - local crate="$1" - local rv - rv=$(awk -F '"' '/^rust-version/ {print $2}' "crates/${crate}/Cargo.toml" 2>/dev/null || echo "") - if [[ -z "$rv" ]]; then - return 0 - fi - # If declared rust-version is greater than MSRV, skip. - if printf '%s\n%s\n' "$MSRV" "$rv" | sort -V | tail -n1 | grep -qx "$rv" && [[ "$rv" != "$MSRV" ]]; then - echo "[pre-push] Skipping MSRV test for ${crate} (rust-version ${rv} > MSRV ${MSRV})" - return 1 - fi - # If crate depends on workspace rmg-core whose rust-version exceeds MSRV, skip as well - if grep -qE '^rmg-core\s*=\s*\{[^}]*path\s*=\s*"\.\./rmg-core"' "crates/${crate}/Cargo.toml" 2>/dev/null; then - local core_rv - core_rv=$(awk -F '"' '/^rust-version/ {print $2}' "crates/rmg-core/Cargo.toml" 2>/dev/null || echo "") - if [[ -n "$core_rv" ]] && printf '%s\n%s\n' "$MSRV" "$core_rv" | sort -V | tail -n1 | grep -qx "$core_rv" && [[ "$core_rv" != "$MSRV" ]]; then - echo "[pre-push] Skipping MSRV test for ${crate} (depends on rmg-core ${core_rv} > MSRV ${MSRV})" - return 1 - fi - fi - return 0 - } - if msrv_ok rmg-core; then cargo +"$MSRV" test -p rmg-core --all-targets; fi - if msrv_ok rmg-geom; then cargo +"$MSRV" test -p rmg-geom --all-targets; fi -fi +# MSRV lane removed: policy is stable everywhere. # Rustdoc warnings guard (public crates) -echo "[pre-push] rustdoc warnings gate (rmg-core @ $PINNED)" -RUSTDOCFLAGS="-D warnings" cargo +"$PINNED" doc -p rmg-core --no-deps -echo "[pre-push] rustdoc warnings gate (rmg-geom @ $PINNED)" -RUSTDOCFLAGS="-D warnings" cargo +"$PINNED" doc -p rmg-geom --no-deps +required_crates=(rmg-core rmg-geom) +optional_crates=(rmg-ffi rmg-wasm) +missing_required=0 + +for krate in "${required_crates[@]}"; do + if [ -f "crates/${krate}/Cargo.toml" ]; then + echo "[pre-push] rustdoc warnings gate (${krate} @ $PINNED)" + RUSTDOCFLAGS="-D warnings" cargo +"$PINNED" doc -p "${krate}" --no-deps + else + echo "[pre-push] ERROR: required crate missing: crates/${krate}/Cargo.toml" >&2 + missing_required=1 + fi +done + +for krate in "${optional_crates[@]}"; do + if [ -f "crates/${krate}/Cargo.toml" ]; then + echo "[pre-push] rustdoc warnings gate (${krate} @ $PINNED)" + RUSTDOCFLAGS="-D warnings" cargo +"$PINNED" doc -p "${krate}" --no-deps + else + echo "[pre-push] skipping ${krate}: missing crates/${krate}/Cargo.toml" + fi +done + +if [ "$missing_required" -ne 0 ]; then + echo "[pre-push] One or more required crates are missing; aborting push." >&2 + exit 1 +fi # Banned patterns echo "[pre-push] scanning banned patterns" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 911f810..58e148b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: false - - uses: dtolnay/rust-toolchain@1.71.1 + - uses: dtolnay/rust-toolchain@1.90.0 - uses: Swatinem/rust-cache@v2 with: workspaces: | @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: false - - uses: dtolnay/rust-toolchain@1.71.1 + - uses: dtolnay/rust-toolchain@1.90.0 with: components: clippy - uses: Swatinem/rust-cache@v2 @@ -47,7 +47,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: false - - uses: dtolnay/rust-toolchain@1.71.1 + - uses: dtolnay/rust-toolchain@1.90.0 - uses: Swatinem/rust-cache@v2 with: workspaces: | @@ -89,15 +89,47 @@ jobs: exit 1; } + # MSRV job removed per policy: use @stable everywhere rustdoc: - name: Rustdoc (rmg-core warnings gate) + name: Rustdoc (warnings gate) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: submodules: false - - uses: dtolnay/rust-toolchain@1.71.1 + - uses: dtolnay/rust-toolchain@1.90.0 - uses: Swatinem/rust-cache@v2 - - name: rustdoc warnings gate + - name: rustdoc warnings gate (rmg-core) run: RUSTDOCFLAGS="-D warnings" cargo doc -p rmg-core --no-deps + - name: rustdoc warnings gate (rmg-geom) + run: RUSTDOCFLAGS="-D warnings" cargo doc -p rmg-geom --no-deps + - name: rustdoc warnings gate (rmg-ffi) + run: | + if [ -f crates/rmg-ffi/Cargo.toml ]; then RUSTDOCFLAGS="-D warnings" cargo doc -p rmg-ffi --no-deps; fi + - name: rustdoc warnings gate (rmg-wasm) + run: | + if [ -f crates/rmg-wasm/Cargo.toml ]; then RUSTDOCFLAGS="-D warnings" cargo doc -p rmg-wasm --no-deps; fi + + audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install cargo-audit (latest) + run: cargo install cargo-audit --locked + - name: Run cargo audit + env: + CARGO_TERM_COLOR: always + run: cargo audit --deny warnings + + deny: + name: Dependency Policy (cargo-deny) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run cargo-deny + uses: EmbarkStudios/cargo-deny-action@v1 diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..d5c3c7f --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,33 @@ +name: Security Audit + +on: + push: + branches: + - main + - "echo/**" + - "feat/**" + pull_request: + +jobs: + audit: + name: Cargo Audit (stable) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: dtolnay/rust-toolchain@1.90.0 + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cargo/bin/cargo-audit + key: audit-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + - name: Install cargo-audit + run: | + if ! command -v cargo-audit >/dev/null; then + cargo install cargo-audit --locked + fi + - name: Run cargo audit + run: cargo audit --deny warnings diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4bdd01c..8bf8c99 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,22 @@ Echo is a deterministic, renderer-agnostic engine. We prioritize: - A minimal docs-guard: when core API files change, it requires updating `docs/execution-plan.md` and `docs/decision-log.md` (mirrors CI) - To auto-fix formatting on commit: `ECHO_AUTO_FMT=1 git commit -m "message"` +#### Partial Staging & rustfmt +- rustfmt formats entire files, not only staged hunks. To preserve index integrity, our pre-commit hook now aborts the commit if running `cargo fmt` would change any files. It first checks with `cargo fmt --check`, and if changes are needed it applies them and exits with a helpful message. +- Workflow when this happens: + 1) Review formatting changes: `git status` and `git diff`. + 2) Restage intentionally formatted files (e.g., `git add -A` or `git add -p`). + 3) Commit again. +- Tips: + - If you need to keep a partial-staged commit, do two commits: first commit the formatter-only changes, then commit your code changes. + - You can switch to check-only with `ECHO_AUTO_FMT=0` (commit will still fail on formatting issues, but nothing is auto-applied). +- Do not bypass hooks. The repo runs fmt, clippy, tests, and rustdoc on the pinned toolchain before push. +- Toolchain: pinned to Rust 1.90.0. Ensure your local override matches: + + - rustup toolchain install 1.90.0 + - rustup override set 1.90.0 +- When any Rust code changes (.rs anywhere), update both `docs/execution-plan.md` and `docs/decision-log.md` with intent and a brief rationale. The hook enforces this. + ## Communication - Major updates should land in `docs/execution-plan.md` and `docs/decision-log.md`; rely on GitHub discussions or issues for longer-form proposals. - Respect the temporal theme—leave the codebase cleaner for the next timeline traveler. diff --git a/Cargo.toml b/Cargo.toml index 3b91a85..f86bcb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,9 @@ members = [ ] resolver = "2" +[workspace.dependencies] +rmg-core = { version = "0.1.0", path = "crates/rmg-core" } + [profile.release] opt-level = "s" lto = true diff --git a/README.md b/README.md index dadfeff..aece16b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ Most game engines are object-oriented state machines. Unity, Unreal, Godot all maintain mutable object hierarchies that update every frame. Echo says: "No, everything is a graph, and the engine rewrites that graph deterministically using typed transformation rules." +## Snapshot Hashes + +Echo records two hashes during a commit: +- `state_root`: deterministic hash of the reachable graph state under the current root. +- `commit hash` (commit_id): hash of a canonical header including `state_root`, parents, and deterministic digests for plan/decisions/rewrites. + +See `docs/spec-merkle-commit.md` for the precise encoding and invariants. + Echo is fundamentally **built different**. RMG provides atomic, in-place edits of recursive meta-graphs with deterministic local scheduling and snapshot isolation. diff --git a/crates/rmg-core/src/footprint.rs b/crates/rmg-core/src/footprint.rs index 64ec02f..d6eea07 100644 --- a/crates/rmg-core/src/footprint.rs +++ b/crates/rmg-core/src/footprint.rs @@ -18,8 +18,8 @@ use crate::ident::{EdgeId, Hash, NodeId}; /// conflicts on boundary interfaces. The engine only requires stable equality /// and ordering; it does not rely on a specific bit layout. /// -/// For demos/tests, use [`pack_port_key`] to derive a deterministic 64‑bit key -/// from a [`NodeId`], a `port_id`, and a direction flag. +/// For demos/tests, use [`pack_port_key`](crate::footprint::pack_port_key) to derive a +/// deterministic 64‑bit key from a [`NodeId`], a `port_id`, and a direction flag. pub type PortKey = u64; /// Simple ordered set of 256‑bit ids based on `BTreeSet` for deterministic diff --git a/crates/rmg-core/src/lib.rs b/crates/rmg-core/src/lib.rs index e443fb2..237a32b 100644 --- a/crates/rmg-core/src/lib.rs +++ b/crates/rmg-core/src/lib.rs @@ -30,6 +30,8 @@ clippy::module_name_repetitions, clippy::use_self )] +// Permit intentional name repetition for public API clarity (e.g., FooFoo types) and +// functions named after their module for discoverability (e.g., `motion_rule`). /// Deterministic math subsystem (Vec3, Mat4, Quat, PRNG). pub mod math; diff --git a/crates/rmg-core/src/math/mat4.rs b/crates/rmg-core/src/math/mat4.rs index 6891fe3..05eb0f8 100644 --- a/crates/rmg-core/src/math/mat4.rs +++ b/crates/rmg-core/src/math/mat4.rs @@ -61,9 +61,12 @@ impl Mat4 { /// looking down the +X axis toward the origin. See /// [`Mat4::rotation_from_euler`] for the full convention. pub fn rotation_x(angle: f32) -> Self { - let (s, c) = angle.sin_cos(); + let (s_raw, c_raw) = angle.sin_cos(); + let s = if s_raw == 0.0 { 0.0 } else { s_raw }; + let c = if c_raw == 0.0 { 0.0 } else { c_raw }; + let ns = if s == 0.0 { 0.0 } else { -s }; Self::new([ - 1.0, 0.0, 0.0, 0.0, 0.0, c, s, 0.0, 0.0, -s, c, 0.0, 0.0, 0.0, 0.0, 1.0, + 1.0, 0.0, 0.0, 0.0, 0.0, c, s, 0.0, 0.0, ns, c, 0.0, 0.0, 0.0, 0.0, 1.0, ]) } @@ -73,9 +76,12 @@ impl Mat4 { /// looking down the +Y axis toward the origin. See /// [`Mat4::rotation_from_euler`] for the full convention. pub fn rotation_y(angle: f32) -> Self { - let (s, c) = angle.sin_cos(); + let (s_raw, c_raw) = angle.sin_cos(); + let s = if s_raw == 0.0 { 0.0 } else { s_raw }; + let c = if c_raw == 0.0 { 0.0 } else { c_raw }; + let ns = if s == 0.0 { 0.0 } else { -s }; Self::new([ - c, 0.0, -s, 0.0, 0.0, 1.0, 0.0, 0.0, s, 0.0, c, 0.0, 0.0, 0.0, 0.0, 1.0, + c, 0.0, ns, 0.0, 0.0, 1.0, 0.0, 0.0, s, 0.0, c, 0.0, 0.0, 0.0, 0.0, 1.0, ]) } @@ -85,9 +91,12 @@ impl Mat4 { /// looking down the +Z axis toward the origin. See /// [`Mat4::rotation_from_euler`] for the full convention. pub fn rotation_z(angle: f32) -> Self { - let (s, c) = angle.sin_cos(); + let (s_raw, c_raw) = angle.sin_cos(); + let s = if s_raw == 0.0 { 0.0 } else { s_raw }; + let c = if c_raw == 0.0 { 0.0 } else { c_raw }; + let ns = if s == 0.0 { 0.0 } else { -s }; Self::new([ - c, s, 0.0, 0.0, -s, c, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + c, s, 0.0, 0.0, ns, c, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, ]) } @@ -144,7 +153,7 @@ impl Mat4 { /// Multiplies the matrix with another matrix (`self * rhs`). /// /// Multiplication follows column-major semantics (`self` on the left, - /// rhs on the right) to mirror GPU-style transforms. + /// `rhs` on the right) to mirror GPU-style transforms. pub fn multiply(&self, rhs: &Self) -> Self { let mut out = [0.0; 16]; for row in 0..4 { @@ -231,3 +240,15 @@ impl Default for Mat4 { Self::identity() } } + +impl core::ops::MulAssign for Mat4 { + fn mul_assign(&mut self, rhs: Mat4) { + *self = self.multiply(&rhs); + } +} + +impl core::ops::MulAssign<&Mat4> for Mat4 { + fn mul_assign(&mut self, rhs: &Mat4) { + *self = self.multiply(rhs); + } +} diff --git a/crates/rmg-core/src/math/mod.rs b/crates/rmg-core/src/math/mod.rs index de28c16..69634cf 100644 --- a/crates/rmg-core/src/math/mod.rs +++ b/crates/rmg-core/src/math/mod.rs @@ -35,6 +35,7 @@ pub const EPSILON: f32 = 1e-6; /// # NaN handling /// If `value`, `min`, or `max` is `NaN`, the result is `NaN`. Callers must /// ensure inputs are finite if deterministic behavior is required. +#[allow(clippy::manual_clamp)] pub fn clamp(value: f32, min: f32, max: f32) -> f32 { assert!(min <= max, "invalid clamp range: {min} > {max}"); value.clamp(min, max) diff --git a/crates/rmg-core/src/record.rs b/crates/rmg-core/src/record.rs index 4b49466..053ae09 100644 --- a/crates/rmg-core/src/record.rs +++ b/crates/rmg-core/src/record.rs @@ -7,6 +7,11 @@ use crate::ident::{EdgeId, NodeId, TypeId}; /// /// The optional `payload` carries domain-specific bytes (component data, /// attachments, etc) and is interpreted by higher layers. +/// +/// Invariants +/// - `ty` must be a valid type identifier in the current schema. +/// - The node identifier is not embedded here; the store supplies it externally. +/// - `payload` encoding is caller-defined and opaque to the store. #[derive(Clone, Debug)] pub struct NodeRecord { /// Type identifier describing the node. @@ -16,6 +21,12 @@ pub struct NodeRecord { } /// Materialised record for a single edge stored in the graph. +/// +/// Invariants +/// - `from` and `to` reference existing nodes in the same store. +/// - `id` is stable across runs for the same logical edge. +/// - `ty` must be a valid edge type in the current schema. +/// - `payload` encoding is caller-defined and opaque to the store. #[derive(Clone, Debug)] pub struct EdgeRecord { /// Stable identifier for the edge. diff --git a/crates/rmg-core/src/scheduler.rs b/crates/rmg-core/src/scheduler.rs index feec645..bfc1901 100644 --- a/crates/rmg-core/src/scheduler.rs +++ b/crates/rmg-core/src/scheduler.rs @@ -114,3 +114,46 @@ impl DeterministicScheduler { self.pending.remove(&tx); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ident::{make_node_id, Hash}; + + fn h(byte: u8) -> Hash { + let mut out = [0u8; 32]; + out[0] = byte; + out + } + + #[test] + fn drain_for_tx_returns_deterministic_order() { + let tx = TxId::from_raw(1); + let scope = make_node_id("s"); + let mut sched = DeterministicScheduler::default(); + let mut map: BTreeMap<(Hash, Hash), PendingRewrite> = BTreeMap::new(); + + // Insert out of lexicographic order: keys (2,1), (1,2), (1,1) + for (scope_h, rule_h) in &[(h(2), h(1)), (h(1), h(2)), (h(1), h(1))] { + map.insert( + (*scope_h, *rule_h), + PendingRewrite { + rule_id: *rule_h, + compact_rule: CompactRuleId(0), + scope_hash: *scope_h, + scope, + footprint: Footprint::default(), + phase: RewritePhase::Matched, + }, + ); + } + sched.pending.insert(tx, map); + + let drained = sched.drain_for_tx(tx); + let keys: Vec<(u8, u8)> = drained + .iter() + .map(|pr| (pr.scope_hash[0], pr.rule_id[0])) + .collect(); + assert_eq!(keys, vec![(1, 1), (1, 2), (2, 1)]); + } +} diff --git a/crates/rmg-core/src/snapshot.rs b/crates/rmg-core/src/snapshot.rs index ebb1c98..d228e19 100644 --- a/crates/rmg-core/src/snapshot.rs +++ b/crates/rmg-core/src/snapshot.rs @@ -1,5 +1,9 @@ //! Snapshot type and hash computation. //! +//! See the high-level spec in `docs/spec-merkle-commit.md` for precise +//! definitions of `state_root` (graph-only hash) and `commit hash` (aka +//! `commit_id`: `state_root` + metadata + parents). +//! //! Determinism contract //! - The graph state hash (`state_root`) is a BLAKE3 digest over a canonical //! byte stream that encodes the entire reachable graph state for the current @@ -38,7 +42,7 @@ use crate::tx::TxId; pub struct Snapshot { /// Node identifier that serves as the root of the snapshot. pub root: NodeId, - /// Canonical commit hash derived from state_root + metadata (see below). + /// Canonical commit hash derived from `state_root` + metadata (see below). pub hash: Hash, /// Parent snapshot hashes (empty for initial commit, 1 for linear history, 2+ for merges). pub parents: Vec, diff --git a/crates/rmg-core/tests/duplicate_rule_registration_tests.rs b/crates/rmg-core/tests/duplicate_rule_registration_tests.rs new file mode 100644 index 0000000..1cbd343 --- /dev/null +++ b/crates/rmg-core/tests/duplicate_rule_registration_tests.rs @@ -0,0 +1,77 @@ +#![allow(missing_docs)] +use blake3::Hasher; +use rmg_core::{ + make_node_id, make_type_id, ConflictPolicy, Engine, GraphStore, NodeRecord, PatternGraph, + RewriteRule, +}; + +fn noop_match(_: &GraphStore, _: &rmg_core::NodeId) -> bool { + true +} +fn noop_exec(_: &mut GraphStore, _: &rmg_core::NodeId) {} +fn noop_fp(_: &GraphStore, _: &rmg_core::NodeId) -> rmg_core::Footprint { + rmg_core::Footprint::default() +} + +#[test] +fn registering_duplicate_rule_name_is_rejected() { + let mut store = GraphStore::default(); + let root = make_node_id("dup-root"); + let world_ty = make_type_id("world"); + store.insert_node( + root, + NodeRecord { + ty: world_ty, + payload: None, + }, + ); + let mut engine = Engine::new(store, root); + engine.register_rule(rmg_core::motion_rule()).unwrap(); + let err = engine.register_rule(rmg_core::motion_rule()).unwrap_err(); + match err { + rmg_core::EngineError::DuplicateRuleName(name) => { + assert_eq!(name, rmg_core::MOTION_RULE_NAME) + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn registering_duplicate_rule_id_is_rejected() { + let mut store = GraphStore::default(); + let root = make_node_id("dup-root2"); + let world_ty = make_type_id("world"); + store.insert_node( + root, + NodeRecord { + ty: world_ty, + payload: None, + }, + ); + let mut engine = Engine::new(store, root); + engine.register_rule(rmg_core::motion_rule()).unwrap(); + + // Compute the same family id used by the motion rule. + let mut hasher = Hasher::new(); + hasher.update(b"rule:"); + hasher.update(rmg_core::MOTION_RULE_NAME.as_bytes()); + let same_id: rmg_core::Hash = hasher.finalize().into(); + + let duplicate = RewriteRule { + id: same_id, + name: "motion/duplicate", + left: PatternGraph { nodes: vec![] }, + matcher: noop_match, + executor: noop_exec, + compute_footprint: noop_fp, + factor_mask: 0, + conflict_policy: ConflictPolicy::Abort, + join_fn: None, + }; + + let err = engine.register_rule(duplicate).unwrap_err(); + match err { + rmg_core::EngineError::DuplicateRuleId(id) => assert_eq!(id, same_id), + other => panic!("unexpected error: {other:?}"), + } +} diff --git a/crates/rmg-core/tests/mat4_mul_tests.rs b/crates/rmg-core/tests/mat4_mul_tests.rs index 534265e..b741b0e 100644 --- a/crates/rmg-core/tests/mat4_mul_tests.rs +++ b/crates/rmg-core/tests/mat4_mul_tests.rs @@ -23,3 +23,168 @@ fn mat4_mul_operator_matches_method() { let meth2 = s.multiply(&id); approx_eq16(op2.to_array(), meth2.to_array()); } + +#[test] +fn mat4_mul_assign_variants_work() { + use core::f32::consts::{FRAC_PI_3, FRAC_PI_4}; + // Owned rhs: non-trivial left-hand (rotation) and right-hand (scale) + let lhs_rot_x = Mat4::rotation_x(FRAC_PI_4); + let rhs_scale = Mat4::scale(2.0, 3.0, 4.0); + let expected_owned = (lhs_rot_x * rhs_scale).to_array(); + let lhs_before = lhs_rot_x.to_array(); + let mut a = lhs_rot_x; + a *= rhs_scale; + // In-place result matches operator path and differs from original lhs + approx_eq16(a.to_array(), expected_owned); + assert_ne!(a.to_array(), lhs_before); + + // Borrowed rhs: non-trivial left-hand (rotation) and right-hand (translation) + let lhs_rot_y = Mat4::rotation_y(FRAC_PI_3); + let rhs_trans = Mat4::translation(1.0, 2.0, 3.0); + let expected_borrowed = (lhs_rot_y * rhs_trans).to_array(); + let lhs_b_before = lhs_rot_y.to_array(); + let mut b = lhs_rot_y; + b *= &rhs_trans; + approx_eq16(b.to_array(), expected_borrowed); + assert_ne!(b.to_array(), lhs_b_before); +} + +#[test] +fn rotations_do_not_produce_negative_zero() { + // We target angles that should produce exact zeros in rotation matrices: + // multiples of π/2 yield sin/cos values in { -1, 0, 1 }, which is where + // -0.0 might accidentally appear if we don't canonicalize zeros. We also + // include a couple of intermediate angles as a sanity check (these should + // not introduce exact zeros but must also not yield -0.0 anywhere). + let angles = [ + 0.0, + core::f32::consts::FRAC_PI_6, + core::f32::consts::FRAC_PI_3, + core::f32::consts::FRAC_PI_2, + core::f32::consts::PI, + 3.0 * core::f32::consts::FRAC_PI_2, + 2.0 * core::f32::consts::PI, + ]; + let neg_zero = (-0.0f32).to_bits(); + for &a in &angles { + let axes = [ + ("X", Mat4::rotation_x(a)), + ("Y", Mat4::rotation_y(a)), + ("Z", Mat4::rotation_z(a)), + ]; + for (axis, m) in axes { + for (idx, &e) in m.to_array().iter().enumerate() { + assert_ne!( + e.to_bits(), + neg_zero, + "found -0.0 in rotation_{} matrix at element [{}] for angle {}", + axis, + idx, + a + ); + } + } + } +} + +#[test] +fn mat4_mul_assign_matches_operator_randomized() { + // Deterministic sampling to exercise a variety of transforms (local RNG to + // avoid depending on crate internals from an external test crate). + struct TestRng { + state: u64, + } + impl TestRng { + fn new(seed: u64) -> Self { + Self { state: seed } + } + fn next_u64(&mut self) -> u64 { + // xorshift64* + let mut x = self.state; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.state = x; + x.wrapping_mul(0x2545F4914F6CDD1D) + } + fn next_f32(&mut self) -> f32 { + let bits = ((self.next_u64() >> 41) as u32) | 0x3f80_0000; + f32::from_bits(bits) - 1.0 // [0,1) + } + fn next_int(&mut self, min: i32, max: i32) -> i32 { + assert!(min <= max); + let span = (max as i64 - min as i64 + 1) as u64; + let v = if span.is_power_of_two() { + self.next_u64() & (span - 1) + } else { + let bound = u64::MAX - u64::MAX % span; + loop { + let c = self.next_u64(); + if c < bound { + break c % span; + } + } + }; + (v as i64 + min as i64) as i32 + } + } + let mut rng = TestRng::new(0x00C0_FFEE); + + // Helper to pick a random basic transform + let rand_transform = |rng: &mut TestRng| -> Mat4 { + let choice = rng.next_int(0, 2); + match choice { + 0 => { + // rotation around a random axis among X/Y/Z with angle in [-pi, pi] + let which = rng.next_int(0, 2); + let angle = (rng.next_f32() * 2.0 - 1.0) * core::f32::consts::PI; + match which { + 0 => Mat4::rotation_x(angle), + 1 => Mat4::rotation_y(angle), + _ => Mat4::rotation_z(angle), + } + } + 1 => { + // scale in [0.5, 2.0] + let sx = 0.5 + 1.5 * rng.next_f32(); + let sy = 0.5 + 1.5 * rng.next_f32(); + let sz = 0.5 + 1.5 * rng.next_f32(); + Mat4::scale(sx, sy, sz) + } + _ => { + // translation in [-5, 5] + let tx = (rng.next_f32() * 10.0) - 5.0; + let ty = (rng.next_f32() * 10.0) - 5.0; + let tz = (rng.next_f32() * 10.0) - 5.0; + Mat4::translation(tx, ty, tz) + } + } + }; + + for _ in 0..64 { + let lhs = rand_transform(&mut rng); + let rhs = rand_transform(&mut rng); + + // Owned rhs path + let mut a = lhs; + let expected_owned = (lhs * rhs).to_array(); + a *= rhs; + approx_eq16(a.to_array(), expected_owned); + + // Borrowed rhs path (new sample to avoid aliasing concerns) + let lhs2 = rand_transform(&mut rng); + let rhs2 = rand_transform(&mut rng); + let mut b = lhs2; + let expected_borrowed = (lhs2 * rhs2).to_array(); + b *= &rhs2; + approx_eq16(b.to_array(), expected_borrowed); + + // Composite LHS path: compose two random transforms to probe deeper paths + let lhs_c = rand_transform(&mut rng) * rand_transform(&mut rng); + let rhs_c = rand_transform(&mut rng); + let mut c = lhs_c; + let expected_c = (lhs_c * rhs_c).to_array(); + c *= rhs_c; + approx_eq16(c.to_array(), expected_c); + } +} diff --git a/crates/rmg-ffi/Cargo.toml b/crates/rmg-ffi/Cargo.toml index 522717f..b2d99a2 100644 --- a/crates/rmg-ffi/Cargo.toml +++ b/crates/rmg-ffi/Cargo.toml @@ -14,4 +14,4 @@ categories = ["external-ffi-bindings", "game-engines"] crate-type = ["rlib", "cdylib", "staticlib"] [dependencies] -rmg-core = { path = "../rmg-core" } +rmg-core = { workspace = true } diff --git a/crates/rmg-geom/Cargo.toml b/crates/rmg-geom/Cargo.toml index 6240d67..de0d536 100644 --- a/crates/rmg-geom/Cargo.toml +++ b/crates/rmg-geom/Cargo.toml @@ -2,6 +2,7 @@ name = "rmg-geom" version = "0.1.0" edition = "2021" +rust-version = "1.71.1" description = "Echo geometry primitives: AABB, transforms, temporal proxies, and broad-phase scaffolding" license = "Apache-2.0" repository = "https://github.com/flyingrobots/echo" @@ -10,6 +11,6 @@ keywords = ["echo", "geometry", "aabb", "broad-phase"] categories = ["game-engines", "data-structures"] [dependencies] -rmg-core = { path = "../rmg-core" } +rmg-core = { workspace = true } [dev-dependencies] diff --git a/crates/rmg-geom/tests/geom_broad_tests.rs b/crates/rmg-geom/tests/geom_broad_tests.rs index a24556c..620b3e9 100644 --- a/crates/rmg-geom/tests/geom_broad_tests.rs +++ b/crates/rmg-geom/tests/geom_broad_tests.rs @@ -1,3 +1,6 @@ +#![allow(missing_docs)] +//! Integration tests for rmg-geom broad-phase (AABB tree). + use rmg_core::math::{Quat, Vec3}; use rmg_geom::broad::aabb_tree::{AabbTree, BroadPhase}; use rmg_geom::temporal::timespan::Timespan; diff --git a/crates/rmg-wasm/Cargo.toml b/crates/rmg-wasm/Cargo.toml index 84b8f25..715e9d6 100644 --- a/crates/rmg-wasm/Cargo.toml +++ b/crates/rmg-wasm/Cargo.toml @@ -18,7 +18,7 @@ default = [] console-panic = ["console_error_panic_hook"] [dependencies] -rmg-core = { path = "../rmg-core" } +rmg-core = { workspace = true } wasm-bindgen = "0.2.104" js-sys = "0.3.81" console_error_panic_hook = { version = "0.1.7", optional = true } diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..7099e14 --- /dev/null +++ b/deny.toml @@ -0,0 +1,36 @@ +# cargo-deny configuration for Echo workspace + +[licenses] +# Explicit allowlist of common permissive licenses used in our deps. +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "ISC", + "MIT", + "MIT-0", + "Unicode-3.0", + "Unlicense", +] + +# Disallow copyleft by default; we do not expect GPL-family in the runtime. +copyleft = "deny" + +# Treat unknown or missing license as a failure. +unlicensed = "deny" + +# Confidence threshold for license text detection (default 0.8) +confidence-threshold = 0.8 + +[bans] +# Warn on multiple versions, but don't fail CI for now. +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" + diff --git a/docs/decision-log.md b/docs/decision-log.md index c7296d3..c874d9d 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -14,10 +14,23 @@ | 2025-10-26 | EPI bundle | Adopt entropy, plugin, inspector, runtime config specs (Phase 0.75) | Close causality & extensibility gap | Phase 1 implementation backlog defined | | 2025-10-26 | RMG + Confluence | Adopt RMG v2 (typed DPOi engine) and Confluence synchronization as core architecture | Unify runtime/persistence/tooling on deterministic rewrites | Launch Rust workspace (rmg-core/ffi/wasm/cli), port ECS rules, set up Confluence networking | | 2025-10-27 | Core math split | Split `rmg-core` math into focused submodules (`vec3`, `mat4`, `quat`, `prng`) replacing monolithic `math.rs`. | Improves readability, testability, and aligns with strict linting. | Update imports; no behavior changes intended; follow-up determinism docs in snapshot hashing. | - | 2025-10-27 | PR #7 prep | Extracted math + engine spike into `rmg-core` (split-core-math-engine); added inline rustdoc on canonical snapshot hashing (node/edge order, payload encoding). | Land the isolated, reviewable portion now; keep larger geometry/broad‑phase work split for follow-ups. | After docs update, run fmt/clippy/tests; merge is a fast‑forward over `origin/main`. | + +## Recent Decisions (2025-10-28 onward) + +The following entries use a heading + bullets format for richer context. +| 2025-10-30 | rmg-core determinism hardening | Added reachability-only snapshot hashing; closed tx lifecycle; duplicate rule detection; deterministic scheduler drain order; expanded motion payload docs; tests for duplicate rule name/id and no‑op commit. | Locks determinism contract and surfaces API invariants; prepares PR #7 for a safe merge train. | Clippy clean for rmg-core; workspace push withheld pending further feedback. | | 2025-10-28 | PR #7 merged | Reachability-only snapshot hashing; ports demo registers rule; guarded ports footprint; scheduler `finalize_tx()` clears `pending`; `PortKey` u30 mask; hooks+CI hardened (toolchain pin, rustdoc fixes). | Determinism + memory hygiene; remove test footguns; pass CI with stable toolchain while keeping rmg-core MSRV=1.68. | Queued follow-ups: #13 (Mat4 canonical zero + MulAssign), #14 (geom train), #15 (devcontainer). | | 2025-10-27 | MWMR reserve gate | Engine calls `scheduler.finalize_tx()` at commit; compact rule id used on execute path; per‑tx telemetry summary behind feature. | Enforce independence and clear active frontier deterministically; keep ordering stable with `(scope_hash, family_id)`. | Toolchain pinned to Rust 1.68; add design note for telemetry graph snapshot replay. | + + +## 2025-10-28 — Mat4 canonical zero + MulAssign (PR #13) + +- Decision: Normalize -0.0 from trig constructors in Mat4 and add MulAssign for in-place multiplication. +- Rationale: Avoid bitwise drift in snapshot/matrix comparisons across platforms; improve ergonomics in hot loops. +- Impact: No API breaks. New tests assert no -0.0 in rotation matrices at key angles; added `MulAssign` for owned/&rhs. +- Next: Review feedback; if accepted, apply same canonicalization policy to other math where applicable. + ## 2025-10-28 — Geometry merge train (PR #14) - Decision: Use an integration branch to validate #8 (geom foundation) + #9 (broad-phase AABB) together. @@ -91,3 +104,29 @@ - Decision: `Timespan::fat_aabb` now unions AABBs at start, mid (t=0.5 via nlerp for rotation, lerp for translation/scale), and end. Sampling count is fixed (3) for determinism. - Change: Implement midpoint sampling in `crates/rmg-geom/src/temporal/timespan.rs`; add test `fat_aabb_covers_mid_rotation_with_offset` to ensure mid‑pose is enclosed. - Consequence: Deterministic and more conservative broad‑phase bounds for typical rotation cases without introducing policy/config surface yet; future work may expose a configurable sampling policy. + +## 2025-10-29 — Pre-commit auto-format policy + +- Decision: When `ECHO_AUTO_FMT=1` (default), the pre-commit hook first checks formatting. If changes are needed, it runs `cargo fmt` to update files, then aborts the commit. This preserves index integrity for partially staged files and prevents unintended staging of unrelated hunks. +- Rationale: `rustfmt` formats entire files; auto-restaging could silently defeat partial staging. Aborting makes the workflow explicit: review, restage, retry. +- Consequence: One extra commit attempt in cases where formatting is needed, but safer staging semantics and fewer surprises. Message includes guidance (`git add -p` or `git add -A`). + +## 2025-10-29 — CI + Security hardening + +- Decision: Add `cargo audit` and `cargo-deny` to CI; expand rustdoc warnings gate to all public crates. +- Rationale: Catch vulnerable/deprecated crates and doc regressions early; keep public surface clean. +- Consequence: Faster failures on dependency or doc issues; small CI time increase. +- Notes: + - Use `rustsec/audit-check@v1` for the audit step; avoid pinning to non-existent tags. + - Add `deny.toml` with an explicit license allowlist to prevent false positives on permissive licenses (Apache-2.0, MIT, BSD-2/3, CC0-1.0, MIT-0, Unlicense, Unicode-3.0, BSL-1.0, Apache-2.0 WITH LLVM-exception). + - Run cargo-audit on Rust 1.75.0 (via `RUSTUP_TOOLCHAIN=1.75.0`) to meet its MSRV; this does not change the workspace MSRV (1.71.1). + +## 2025-10-29 — Snapshot commit spec (v1) + +- Decision: Introduce `docs/spec-merkle-commit.md` describing `state_root` vs `commit_id` encodings and invariants. +- Rationale: Make provenance explicit and discoverable; align code comments with a durable spec. +- Changes: Linked spec from `crates/rmg-core/src/snapshot.rs` and README. + +| 2025-10-30 | CI toolchain simplification | Standardize on Rust `@stable` across CI (fmt, clippy, tests, security audit); remove MSRV job; set `rust-toolchain.toml` to `stable`. | Reduce toolchain drift and recurring audit/MSRV mismatches. | Future MSRV tracking can move to release notes when needed. | +| 2025-10-30 | Rustdoc pedantic cleanup | Snapshot docs clarify `state_root` with code formatting to satisfy `clippy::doc_markdown`. | Keep strict lint gates green; no behavior change. | None. | +| 2025-10-30 | Spec + lint hygiene | Removed duplicate `clippy::module_name_repetitions` allow in `rmg-core/src/lib.rs`. Clarified `docs/spec-merkle-commit.md`: `edge_count` is u64 LE and may be 0; genesis commits have length=0 parents; “empty digest” explicitly defined as `blake3(b"")`; v1 mandates empty `decision_digest` until Aion lands. | Codifies intent; prevents ambiguity for implementers. | No code behavior changes; spec is clearer. | diff --git a/docs/execution-plan.md b/docs/execution-plan.md index 3dcee9f..712630d 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -38,6 +38,29 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s - Update `rmg-geom::temporal::Timespan::fat_aabb` to union AABBs at start, mid (t=0.5), and end to conservatively bound rotations about off‑centre pivots. - Add test `fat_aabb_covers_mid_rotation_with_offset` to verify the fat box encloses the mid‑pose AABB. +> 2025-10-29 — Pre-commit format policy + +- Change auto-format behavior: when `cargo fmt` would modify files, the hook now applies formatting then aborts the commit with guidance to review and restage. This preserves partial-staging semantics and avoids accidentally staging unrelated hunks. + +> 2025-10-29 — CI/security hardening + +- CI now includes `cargo audit` and `cargo-deny` jobs to catch vulnerable/deprecated dependencies early. +- Rustdoc warnings gate covers rmg-core, rmg-geom, rmg-ffi, and rmg-wasm. +- Devcontainer runs `make hooks` post-create to install repo hooks by default. +- Note: switched audit action to `rustsec/audit-check@v1` (previous attempt to pin a non-existent tag failed). +- Added `deny.toml` with an explicit permissive-license allowlist (Apache-2.0, MIT, BSD-2/3, CC0-1.0, MIT-0, Unlicense, Unicode-3.0, BSL-1.0, Apache-2.0 WITH LLVM-exception) to align cargo-deny with our dependency set. + - Audit job runs `cargo audit` on Rust 1.75.0 (explicit `RUSTUP_TOOLCHAIN=1.75.0`) to satisfy tool MSRV; workspace MSRV remains 1.71.1. + +> 2025-10-29 — Snapshot commit spec + +- Added `docs/spec-merkle-commit.md` defining `state_root` vs `commit_id` encoding and invariants. +- Linked the spec from `crates/rmg-core/src/snapshot.rs` and README. + +> 2025-10-28 — PR #13 (math polish) opened + +- Focus: canonicalize -0.0 in Mat4 trig constructors and add MulAssign ergonomics. +- Outcome: Opened PR echo/core-math-canonical-zero with tests; gather feedback before merge. + > 2025-10-29 — Hooks formatting gate (PR #12) - Pre-commit: add rustfmt check for staged Rust files (`cargo fmt --all -- --check`). @@ -94,6 +117,26 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s - Landed on main; see Decision Log for summary of changes and CI outcomes. +> 2025-10-30 — rmg-core determinism tests and API hardening + +- **Focus**: Address PR feedback for the split-core-math-engine branch. Add tests for snapshot reachability, tx lifecycle, scheduler drain order, and duplicate rule registration. Harden API docs and FFI (TxId repr, const ctors). +- **Definition of done**: `cargo test -p rmg-core` passes; clippy clean for rmg-core with strict gates; no workspace pushes yet (hold for more feedback). + +> 2025-10-30 — CI toolchain policy: use stable everywhere + +- **Focus**: Simplify CI by standardizing on `@stable` toolchain (fmt, clippy, tests, audit). Remove MSRV job; developers default to stable via `rust-toolchain.toml`. +- **Definition of done**: CI workflows updated; Security Audit uses latest cargo-audit on stable; docs updated. + +> 2025-10-30 — Minor rustdoc/lint cleanups (rmg-core) + +- **Focus**: Address clippy::doc_markdown warning by clarifying Snapshot docs (`state_root` backticks). +- **Definition of done**: Lints pass under pedantic; no behavior changes. + +> 2025-10-30 — Spec + lint hygiene (core) + +- **Focus**: Remove duplicate clippy allow in `crates/rmg-core/src/lib.rs`; clarify `docs/spec-merkle-commit.md` (edge_count may be 0; explicit empty digests; genesis parents). +- **Definition of done**: Docs updated; clippy clean. + --- ## Immediate Backlog diff --git a/docs/spec-merkle-commit.md b/docs/spec-merkle-commit.md new file mode 100644 index 0000000..825459e --- /dev/null +++ b/docs/spec-merkle-commit.md @@ -0,0 +1,56 @@ +# Snapshot Commit Spec (v1) + +This document precisely defines the two hashes produced by the engine when recording state and provenance. + +- state_root: BLAKE3 of the canonical encoding of the reachable graph under the current root. +- commit hash (commit_id): BLAKE3 of a header that includes state_root, parent commit(s), and deterministic digests of plan/decisions/rewrites, plus a policy id. + +## 1. Canonical Graph Encoding (state_root) + +Inputs: GraphStore, root NodeId. + +Deterministic traversal: +- Reachability: BFS from root following outbound edges; only reachable nodes and edges are included. +- Node order: ascending NodeId (lexicographic over 32-byte ids). +- Edge order: for each source node, include only edges whose destination is reachable; sort by ascending EdgeId. + +Encoding (little-endian where applicable): +- Root id: 32 bytes. +- For each node (in order): + - node_id (32), node.ty (32), payload_len (u64 LE), payload bytes. +- For each source (in order): + - from_id (32), edge_count (u64 LE) of included edges. + - edge_count is a 64-bit little-endian integer and may be 0 when a source + node has no outbound edges included by reachability/ordering rules. + - For each edge (in order): + - edge.id (32), edge.ty (32), edge.to (32), payload_len (u64 LE), payload bytes. + +Hash: blake3(encoding) → 32-byte digest. + +## 2. Commit Header (commit_id) + +Header fields (v1): +- version: u16 = 1 +- parents: Vec (length u64 LE, then each 32-byte hash). Genesis commits + have zero parents (length = 0). +- state_root: 32 bytes (from section 1) +- plan_digest: 32 bytes (canonical digest of ready-set ordering; empty list = the + BLAKE3 hash of a zero-length byte sequence, i.e., blake3(b"")) +- decision_digest: 32 bytes (Aion/agency inputs; v1 uses the empty digest until + Aion integration) +- rewrites_digest: 32 bytes (ordered rewrites applied) +- policy_id: u32 (version pin for Aion policy) + +Hash: blake3(encode(header)) → commit_id. + +## 3. Invariants and Notes + +- Any change to ordering, lengths, or endianness breaks all prior hashes. +- The commit_id is stable across identical states and provenance, independent of runtime. +- The canonical empty digest is the BLAKE3 hash of a zero-length byte sequence + (blake3(b"")); use this for empty plan/rewrites/decisions until populated. + +## 4. Future Evolution + +- v2 may add additional fields (e.g., signer, timestamp) and bump header version. +- Migrations must document how to re-compute commit_id for archival data. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7f9aa4d..43e5784 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.71.1" +channel = "1.90.0" components = ["rustfmt", "clippy"]