From eaeecd8daddd092da7316a64e0fcb8a9e1fb5fba Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:34:21 +0200 Subject: [PATCH 1/2] refactor: typed atlas artifact files, atomic generation root, options --- Cargo.lock | 58 +- Cargo.toml | 6 +- libs/@local/graph/atlas/Cargo.toml | 8 +- libs/@local/graph/atlas/README.md | 114 +- .../graph/atlas/benches/backfill_walk.rs | 14 +- .../@local/graph/atlas/benches/miner_index.rs | 2 +- libs/@local/graph/atlas/docs/wire.md | 52 +- libs/@local/graph/atlas/src/allocator.rs | 25 +- .../@local/graph/atlas/src/bitset/compress.rs | 28 +- libs/@local/graph/atlas/src/bitset/dense.rs | 129 +- libs/@local/graph/atlas/src/bitset/mod.rs | 1 - libs/@local/graph/atlas/src/bitset/tests.rs | 90 +- libs/@local/graph/atlas/src/cli/fit.rs | 21 +- libs/@local/graph/atlas/src/cli/shell.rs | 101 +- .../graph/atlas/src/cli/tui/render/rail.rs | 2 +- .../graph/atlas/src/cli/tui/render/tests.rs | 40 +- .../graph/atlas/src/dataset/auxiliary.rs | 7 +- .../graph/atlas/src/dataset/card/contents.rs | 2 +- .../graph/atlas/src/dataset/card/hash/mod.rs | 4 +- .../graph/atlas/src/dataset/card/lint.rs | 9 +- .../graph/atlas/src/dataset/card/select.rs | 5 +- .../atlas/src/dataset/offline/dump/archive.rs | 3 +- .../atlas/src/dataset/offline/embedder.rs | 4 +- .../graph/atlas/src/dataset/offline/tests.rs | 74 +- libs/@local/graph/atlas/src/file/array/mod.rs | 10 +- .../@local/graph/atlas/src/file/array/read.rs | 137 ++- .../graph/atlas/src/file/array/tests.rs | 21 +- .../graph/atlas/src/file/attraction/mod.rs | 4 +- .../graph/atlas/src/file/classifier/mod.rs | 2 +- .../graph/atlas/src/file/generation/error.rs | 223 ++++ .../graph/atlas/src/file/generation/mod.rs | 455 ++----- .../graph/atlas/src/file/generation/open.rs | 63 +- .../atlas/src/file/generation/scratch.rs | 56 + .../atlas/src/file/generation/staging.rs | 214 ++++ .../graph/atlas/src/file/generation/tests.rs | 231 +++- .../graph/atlas/src/file/identity/mod.rs | 30 +- .../graph/atlas/src/file/identity/read.rs | 19 +- .../graph/atlas/src/file/identity/tests.rs | 5 +- .../graph/atlas/src/file/landmark/mod.rs | 2 +- libs/@local/graph/atlas/src/file/mod.rs | 30 +- .../@local/graph/atlas/src/file/morton/mod.rs | 5 +- .../graph/atlas/src/file/morton/read.rs | 21 +- .../graph/atlas/src/file/morton/tests.rs | 7 +- .../@local/graph/atlas/src/file/policy/mod.rs | 2 +- .../graph/atlas/src/file/postings/mod.rs | 2 +- .../graph/atlas/src/file/postings/read.rs | 2 +- libs/@local/graph/atlas/src/file/quad/mod.rs | 7 +- libs/@local/graph/atlas/src/file/quad/read.rs | 40 +- .../@local/graph/atlas/src/file/quad/tests.rs | 62 +- .../graph/atlas/src/file/region/header.rs | 4 +- .../graph/atlas/src/file/region/tests.rs | 8 +- .../graph/atlas/src/file/repository/mod.rs | 66 +- .../graph/atlas/src/file/salt/artifact.rs | 25 +- .../graph/atlas/src/file/salt/metadata.rs | 223 +--- .../@local/graph/atlas/src/file/salt/tests.rs | 64 +- libs/@local/graph/atlas/src/file/sprs/mod.rs | 6 +- libs/@local/graph/atlas/src/file/sprs/read.rs | 6 +- .../@local/graph/atlas/src/identity/column.rs | 88 +- libs/@local/graph/atlas/src/identity/node.rs | 2 +- libs/@local/graph/atlas/src/integrity/hash.rs | 30 +- libs/@local/graph/atlas/src/integrity/hex.rs | 15 +- .../graph/atlas/src/integrity/secret.rs | 10 +- libs/@local/graph/atlas/src/lib.rs | 6 +- .../graph/atlas/src/math/affinity/fit.rs | 2 +- libs/@local/graph/atlas/src/morton/mod.rs | 384 +++++- libs/@local/graph/atlas/src/morton/tests.rs | 103 +- libs/@local/graph/atlas/src/offload.rs | 279 ++++- .../atlas/src/postgres/card/associations.rs | 7 +- .../graph/atlas/src/postgres/card/examples.rs | 17 +- libs/@local/graph/atlas/src/postgres/id.rs | 35 +- libs/@local/graph/atlas/src/progress.rs | 5 +- libs/@local/graph/atlas/src/random/mod.rs | 3 +- libs/@local/graph/atlas/src/random/tests.rs | 6 +- .../atlas/src/salt/adjacency/artifact.rs | 64 +- .../graph/atlas/src/salt/adjacency/mod.rs | 4 +- .../graph/atlas/src/salt/adjacency/tests.rs | 4 +- .../src/salt/embedding/external/tests.rs | 30 +- .../graph/atlas/src/salt/embedding/tests.rs | 6 +- .../@local/graph/atlas/src/salt/file/point.rs | 5 +- .../graph/atlas/src/salt/file/vector.rs | 5 +- .../atlas/src/salt/fit/compute/classifier.rs | 2 +- .../atlas/src/salt/fit/compute/coordinates.rs | 1 + .../graph/atlas/src/salt/fit/compute/error.rs | 30 +- .../atlas/src/salt/fit/compute/landmark.rs | 10 +- .../graph/atlas/src/salt/fit/compute/lod.rs | 2 +- .../graph/atlas/src/salt/fit/compute/mod.rs | 1 + .../src/salt/fit/compute/projector/inputs.rs | 3 +- .../src/salt/fit/compute/projector/mod.rs | 13 +- .../src/salt/fit/compute/projector/report.rs | 8 +- .../src/salt/fit/compute/projector/tests.rs | 41 +- .../atlas/src/salt/fit/compute/quotient.rs | 4 +- .../atlas/src/salt/fit/compute/relation.rs | 1 + libs/@local/graph/atlas/src/salt/fit/echo.rs | 1046 ----------------- .../@local/graph/atlas/src/salt/fit/ingest.rs | 1 + libs/@local/graph/atlas/src/salt/fit/mod.rs | 193 ++- .../atlas/src/salt/fit/prepare/identity.rs | 175 ++- .../graph/atlas/src/salt/fit/prepare/mod.rs | 3 + .../graph/atlas/src/salt/fit/prepare/norm.rs | 2 +- .../atlas/src/salt/fit/prepare/provider.rs | 70 ++ libs/@local/graph/atlas/src/salt/fit/tests.rs | 377 +++--- .../graph/atlas/src/salt/importance/mod.rs | 8 +- .../graph/atlas/src/salt/knn/descent.rs | 13 +- .../@local/graph/atlas/src/salt/knn/hannoy.rs | 2 +- libs/@local/graph/atlas/src/salt/knn/mod.rs | 5 +- .../@local/graph/atlas/src/salt/knn/recall.rs | 2 +- .../graph/atlas/src/salt/knn/report/mod.rs | 1 + .../graph/atlas/src/salt/knn/report/tests.rs | 12 +- libs/@local/graph/atlas/src/salt/knn/tests.rs | 59 +- .../@local/graph/atlas/src/salt/ladder/mod.rs | 38 +- .../src/salt/ladder/paired/evidence/mod.rs | 11 +- .../src/salt/ladder/paired/evidence/tests.rs | 4 +- .../atlas/src/salt/ladder/paired/fixtures.rs | 11 +- .../src/salt/ladder/paired/identity/mod.rs | 6 +- .../src/salt/ladder/paired/identity/tests.rs | 21 +- .../src/salt/ladder/paired/measure/tests.rs | 2 +- .../src/salt/ladder/paired/movement/mod.rs | 15 +- .../src/salt/ladder/paired/movement/tests.rs | 28 +- .../graph/atlas/src/salt/ladder/report/mod.rs | 5 +- .../graph/atlas/src/salt/landmark/artifact.rs | 8 +- .../graph/atlas/src/salt/landmark/layout.rs | 8 +- .../graph/atlas/src/salt/landmark/quotient.rs | 6 +- .../graph/atlas/src/salt/landmark/select.rs | 14 +- .../graph/atlas/src/salt/landmark/tests.rs | 29 +- libs/@local/graph/atlas/src/salt/lod/bench.rs | 609 +++++----- .../graph/atlas/src/salt/lod/cascade.rs | 21 +- libs/@local/graph/atlas/src/salt/lod/quad.rs | 10 +- libs/@local/graph/atlas/src/salt/lod/stage.rs | 73 +- libs/@local/graph/atlas/src/salt/lod/tests.rs | 45 +- libs/@local/graph/atlas/src/salt/mod.rs | 2 +- .../salt/policy/annotation/assembly/mod.rs | 7 +- .../salt/policy/annotation/assembly/tests.rs | 13 +- .../atlas/src/salt/policy/artifact/mod.rs | 4 +- .../src/salt/policy/classifier/fit/mod.rs | 98 +- .../policy/classifier/fit/regularization.rs | 36 +- .../policy/classifier/fit/solver/config.rs | 314 ++++- .../salt/policy/classifier/fit/solver/mod.rs | 2 +- .../policy/classifier/fit/solver/newton.rs | 2 +- .../policy/classifier/fit/solver/prepare.rs | 15 +- .../classifier/fit/solver/report/probe.rs | 23 +- .../classifier/fit/solver/report/tests.rs | 6 +- .../policy/classifier/fit/solver/solve.rs | 53 +- .../policy/classifier/fit/solver/tests.rs | 183 ++- .../src/salt/policy/classifier/fit/tests.rs | 202 +++- .../atlas/src/salt/policy/classifier/mod.rs | 6 +- .../src/salt/policy/classifier/report/mod.rs | 4 +- .../salt/policy/classifier/report/replay.rs | 3 +- .../@local/graph/atlas/src/salt/policy/mod.rs | 50 +- .../atlas/src/salt/policy/precedence/mod.rs | 8 +- .../atlas/src/salt/policy/precedence/tests.rs | 45 +- .../graph/atlas/src/salt/policy/tests.rs | 32 +- .../graph/atlas/src/salt/postings/artifact.rs | 110 +- .../graph/atlas/src/salt/postings/build.rs | 8 +- .../graph/atlas/src/salt/postings/closure.rs | 77 +- .../graph/atlas/src/salt/postings/tests.rs | 71 +- .../atlas/src/salt/projector/artifact/mod.rs | 16 +- .../src/salt/projector/artifact/tests.rs | 6 +- .../atlas/src/salt/projector/band/tests.rs | 2 +- .../atlas/src/salt/projector/bench/live.rs | 8 +- .../atlas/src/salt/projector/budget/mod.rs | 14 +- .../src/salt/projector/evidence/tests.rs | 2 +- .../atlas/src/salt/projector/gauge/mod.rs | 4 +- .../atlas/src/salt/projector/gauge/tests.rs | 4 +- .../atlas/src/salt/projector/loss/contrast.rs | 5 +- .../atlas/src/salt/projector/loss/energy.rs | 118 +- .../atlas/src/salt/projector/loss/mod.rs | 34 +- .../src/salt/projector/loss/objective/mod.rs | 3 +- .../salt/projector/loss/objective/tests.rs | 2 +- .../atlas/src/salt/projector/loss/penalty.rs | 6 +- .../atlas/src/salt/projector/loss/tests.rs | 72 +- .../atlas/src/salt/projector/miner/mod.rs | 60 +- .../atlas/src/salt/projector/miner/tests.rs | 30 +- .../atlas/src/salt/projector/model/mod.rs | 16 +- .../atlas/src/salt/projector/report/mod.rs | 4 +- .../salt/projector/report/replay/design.rs | 40 +- .../src/salt/projector/report/replay/draw.rs | 1 + .../src/salt/projector/report/replay/error.rs | 2 +- .../salt/projector/report/replay/extract.rs | 29 +- .../projector/report/replay/population.rs | 4 +- .../salt/projector/report/replay/preflight.rs | 20 +- .../src/salt/projector/report/replay/tests.rs | 81 +- .../atlas/src/salt/projector/sample/mod.rs | 2 +- .../atlas/src/salt/projector/sample/tests.rs | 10 +- .../src/salt/projector/scale/frozen/mod.rs | 16 +- .../salt/projector/scale/frozen/refusal.rs | 2 +- .../src/salt/projector/scale/frozen/tests.rs | 5 +- .../atlas/src/salt/projector/scale/mod.rs | 4 +- .../src/salt/projector/train/batch/draw.rs | 6 +- .../src/salt/projector/train/batch/mod.rs | 5 +- .../src/salt/projector/train/fit/error.rs | 14 +- .../src/salt/projector/train/fit/fixture.rs | 62 +- .../atlas/src/salt/projector/train/fit/mod.rs | 26 +- .../src/salt/projector/train/fit/options.rs | 182 +-- .../projector/train/fit/session/boundary.rs | 22 +- .../salt/projector/train/fit/session/mod.rs | 2 +- .../projector/train/fit/session/training.rs | 4 +- .../src/salt/projector/train/fit/tests.rs | 187 +-- .../atlas/src/salt/projector/train/metrics.rs | 48 +- .../atlas/src/salt/projector/train/mod.rs | 99 +- .../atlas/src/salt/projector/train/refresh.rs | 32 + .../atlas/src/salt/projector/train/tests.rs | 83 +- .../graph/atlas/src/salt/quality/clump.rs | 3 +- .../graph/atlas/src/salt/quality/metric.rs | 13 +- .../graph/atlas/src/salt/quality/mod.rs | 2 +- .../atlas/src/salt/quality/probe/options.rs | 58 +- .../graph/atlas/src/salt/quality/runner.rs | 4 +- .../graph/atlas/src/salt/quality/tests.rs | 91 +- .../graph/atlas/src/salt/relation/artifact.rs | 3 +- .../atlas/src/salt/relation/attraction.rs | 11 +- .../atlas/src/salt/relation/bench/fixture.rs | 4 +- .../atlas/src/salt/relation/bench/tests.rs | 9 +- .../graph/atlas/src/salt/relation/build.rs | 45 +- .../graph/atlas/src/salt/relation/mod.rs | 2 +- .../atlas/src/salt/relation/protection.rs | 121 +- .../graph/atlas/src/salt/relation/tests.rs | 52 +- .../@local/graph/atlas/src/salt/runner/mod.rs | 9 +- .../atlas/src/salt/runner/operator/live.rs | 21 +- .../atlas/src/salt/runner/operator/mod.rs | 154 ++- .../atlas/src/salt/runner/operator/offline.rs | 21 +- .../graph/atlas/src/salt/runner/tests.rs | 39 +- .../graph/atlas/src/salt/semantic/artifact.rs | 3 +- .../atlas/src/salt/semantic/bandwidth.rs | 2 +- .../graph/atlas/src/salt/semantic/mod.rs | 14 +- .../graph/atlas/src/salt/semantic/tests.rs | 4 +- libs/@local/hashql/core/src/id/slice.rs | 157 ++- libs/@local/hashql/core/src/id/vec.rs | 16 + 225 files changed, 5856 insertions(+), 4780 deletions(-) create mode 100644 libs/@local/graph/atlas/src/file/generation/error.rs create mode 100644 libs/@local/graph/atlas/src/file/generation/scratch.rs create mode 100644 libs/@local/graph/atlas/src/file/generation/staging.rs delete mode 100644 libs/@local/graph/atlas/src/salt/fit/echo.rs create mode 100644 libs/@local/graph/atlas/src/salt/fit/prepare/provider.rs diff --git a/Cargo.lock b/Cargo.lock index 0317b77037a..d3b79a44773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,7 +93,7 @@ dependencies = [ "cfg-if", "http 1.4.2", "indexmap 2.14.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde", "serde_json", "serde_qs", @@ -5984,6 +5984,7 @@ dependencies = [ "hash-graph-types", "hash-middleware", "hash-temporal-client", + "hashbrown 0.17.1", "hashql-core", "heed", "hkdf 0.13.0", @@ -6005,7 +6006,7 @@ dependencies = [ "regex", "rkyv", "roaring 0.11.4", - "schemars 1.2.1", + "schemars 1.2.2", "serde", "serde_json", "sha2 0.11.0", @@ -6019,6 +6020,7 @@ dependencies = [ "time", "tokio", "tokio-postgres", + "tokio-util", "tower", "tracing", "tracing-subscriber", @@ -12401,9 +12403,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "indexmap 2.14.0", @@ -12415,14 +12417,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", - "syn 2.0.118", + "serde_derive_internals 0.30.0", + "syn 3.0.5", ] [[package]] @@ -12632,9 +12634,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -12662,22 +12664,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.5", ] [[package]] @@ -12691,6 +12693,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -12793,7 +12806,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -13472,6 +13485,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -14779,7 +14803,7 @@ checksum = "9fc2c44dc9fe4baf55b88e032621b7a11b215a1f0a7de8d0aa04367207d915bc" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", + "serde_derive_internals 0.29.1", "syn 2.0.118", ] diff --git a/Cargo.toml b/Cargo.toml index d7b10d1dd84..70b46bac1c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -265,13 +265,13 @@ rpds = { version = "1.1.2", default-features = fal rstest = { version = "0.26.1", default-features = false } rustc_version = { version = "0.4.1", default-features = false } scc = { version = "3.8.4", default-features = false } -schemars = { version = "1.2.1" } +schemars = { version = "1.2.2" } semver = { version = "1.0.27", default-features = false } sentry = { version = "0.48.0", default-features = false, features = ["backtrace", "contexts", "debug-images", "panic", "reqwest", "rustls", "tower-http", "tracing"] } sentry-core = { version = "0.48.0", default-features = false } sentry-types = { version = "0.48.0", default-features = false } -serde = { version = "1.0.228", default-features = false } -serde_core = { version = "1.0.228", default-features = false } +serde = { version = "1.0.229", default-features = false } +serde_core = { version = "1.0.229", default-features = false } serde_json = { version = "1.0.145" } serde_plain = { version = "1.0.2", default-features = false } sha2 = { version = "0.11.0", default-features = false } diff --git a/libs/@local/graph/atlas/Cargo.toml b/libs/@local/graph/atlas/Cargo.toml index 26f7cd19af8..4af846ecf48 100644 --- a/libs/@local/graph/atlas/Cargo.toml +++ b/libs/@local/graph/atlas/Cargo.toml @@ -19,7 +19,7 @@ rand = { workspace = true, public = true, features = ["chacha", "sys_r rand_core = { workspace = true, public = true } schemars = { workspace = true, public = true } sprs = { workspace = true, public = true } -tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync"], public = true } +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync", "time"], public = true } tokio-postgres = { workspace = true, features = ["with-serde_json-1", "with-uuid-1"], public = true } uuid = { workspace = true, public = true, features = ["std", "v7"] } zerocopy = { workspace = true, features = ["alloc", "derive"], public = true } @@ -45,6 +45,7 @@ hash-graph-authorization = { workspace = true } hash-graph-postgres-store = { workspace = true, public = true } hash-graph-store = { workspace = true, features = ["postgres"] } hash-temporal-client = { workspace = true, public = true } +hashbrown = { workspace = true } heed = { workspace = true } hkdf = { workspace = true } kiddo = { workspace = true, features = ["rkyv_08", "simd", "multi-threaded"] } @@ -62,7 +63,7 @@ regex = { workspace = true } rkyv = { workspace = true, public = true, features = ["pointer_width_64"] } roaring = { workspace = true, features = ["std"] } serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true, features = ["float_roundtrip"] } +serde_json = { workspace = true, features = ["float_roundtrip", "raw_value"] } sha2 = { workspace = true } simple-mermaid = { workspace = true } siphasher = { workspace = true } @@ -71,6 +72,7 @@ steppe = { workspace = true } subtle = { workspace = true } tiktoken-rs = { workspace = true } time = { workspace = true } +tokio-util = { workspace = true } tower = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, optional = true, features = ["ansi", "env-filter", "fmt", "std"] } @@ -83,7 +85,7 @@ darwin-kperf-criterion = { workspace = true, features = ["codspeed"] } darwin-kperf-events = { workspace = true } insta = { workspace = true } proptest = { workspace = true, features = ["attr-macro"] } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } tower = { workspace = true, features = ["util"] } tracing-subscriber = { workspace = true, features = ["ansi", "env-filter", "fmt", "std"] } uuid = { workspace = true, features = ["v5"] } diff --git a/libs/@local/graph/atlas/README.md b/libs/@local/graph/atlas/README.md index 496b06fe274..167e5ad1f7e 100644 --- a/libs/@local/graph/atlas/README.md +++ b/libs/@local/graph/atlas/README.md @@ -2,11 +2,11 @@ Fits 2D maps over the entity embeddings stored in the HASH Graph, blending semantic similarity (what entities mean) with relational structure (how they connect), and serves the fitted maps as a read-only HTTP API of binary tiles that a GPU renderer consumes directly. -The design trades flexibility for verifiability: a fit publishes one **immutable, content-addressed generation** of typed binary artifacts, every pipeline stage is deterministic (an equal seed over an equal snapshot replays every draw), and everything downstream is fail-closed - a generation that cannot prove its own integrity does not serve, and the server refuses by name any request it cannot answer exactly rather than answering approximately. +The design trades flexibility for verifiability: a fit publishes one **immutable, content-addressed generation** of typed binary artifacts. For a fixed build, keyed generators reproduce their sequences for equal `(seed, key, stream)` inputs. Replaying a sample also requires the same population, sampler parameters and draw order. A generation that fails validation does not become active. Edges and locate responses distinguish truncation from complete delivery with their `complete` flag. ## Quick start -The workspace pins the required nightly toolchain. Build from the repository root. The operator commands ride the `hash-graph` binary's `atlas` subcommand. Projector devices are selected at runtime through `--device`. macOS uses Metal by default. Other hosts use CUDA by default. Pass `--device cpu` for the cross-platform CPU path. +The workspace pins the required nightly toolchain. Build from the repository root. The operator commands are the `hash-graph` binary's `atlas` subcommand. Select the projector device at runtime through `--device`. macOS uses Metal by default. Other hosts use CUDA by default. Pass `--device cpu` for the cross-platform CPU path. ```sh cargo build -p hash-graph @@ -20,7 +20,7 @@ cargo run -p hash-graph -- \ --annotations annotation-corpus.json ``` -Quality thresholds default to maximally permissive values (the gate demands evidence presence rather than fidelity); impose measured bounds with `--quality-thresholds thresholds.json`: +Quality thresholds default to maximally permissive values, the admission check demanding evidence presence rather than fidelity. Impose measured bounds with `--quality-thresholds thresholds.json`: ```json { @@ -31,7 +31,7 @@ Quality thresholds default to maximally permissive values (the gate demands evid The fields are `minimum_recall`, `minimum_trustworthiness`, `minimum_continuity`, `maximum_intrusion_rate`, `minimum_triplet_agreement` (each in `[0, 1]`) and `maximum_density_spread` (finite, non-negative). Out-of-domain values and unknown fields refuse the run before it starts. -Success prints a receipt and writes an admission report: +Success prints the fit's verdict and writes an admission report: ```text generation 2481c360... @@ -44,46 +44,51 @@ report admission-report.json `hash-graph atlas fit --help` documents the full option set: seeding, landmark capacity, relation-annotation inputs, projector steps, and the baseline escape hatch. -Serve the active generation and read from it (`atlas` with no subcommand serves - the deployment default): +Serve the root's generations and read from them. `serve` is one of the `atlas` subcommand's three, beside `fit` and `healthcheck`, and the subcommand is not optional: ```sh -cargo run -p hash-graph -- atlas --root /var/lib/hash/atlas +cargo run -p hash-graph -- atlas serve --root /var/lib/hash/atlas ``` -The manifest and every data route name their actor in `X-Authenticated-User-Actor-Id`, which the surrounding service sets. `current` and the OpenAPI routes take no actor. A data request also replays the `Atlas-Authority` token the manifest response minted for that actor: +Maintenance starts before the listener binds. The listener can become available before any generation is ready. Maintenance reads `/current` on its own cadence, one second by default, and opens the generation that pointer names, promoting its runtime once it is ready. The process pins no generation at startup, and promotion needs no restart. Until the first promotion the read routes answer 503 `visibility-unavailable` while maintenance retries. `/status` answers 200 from the moment the listener is up. -```sh -actor="00000000-0000-0000-0000-000000000000" # whatever actor the gateway authenticated -generation="$(curl -fsS http://127.0.0.1:4003/v1/atlas/current | jq -r .generation)" -curl -fsS -X POST -D manifest.headers \ - -H "X-Authenticated-User-Actor-Id: ${actor}" \ +Every atlas route runs behind the shared authentication middleware, `/status` alone outside it. A caller presents one of two credentials: a Kratos session (the browser's `ory_kratos_session` cookie or an `X-Session-Token`), or the service credential `Authorization: HASH-Service ` with the delegated actor beside it in `X-Authenticated-User-Actor-Id`. The actor header on its own carries no credential. A data request additionally replays the `Atlas-Authority` token the manifest response issued for that actor: + +```bash +# The actor to delegate for: a principal this deployment's store knows. The nil UUID is the +# encoding for acting for nobody, and these routes admit no anonymous caller, so it is refused +# here rather than treated as a placeholder. +actor="${ATLAS_ACTOR_ID:?set to an actor the principal store knows}" +service="${HASH_GRAPH_SERVICE_SECRET}" # the shared internal-service secret +credentials=(-H "Authorization: HASH-Service ${service}" -H "X-Authenticated-User-Actor-Id: ${actor}") +generation="$(curl -fsS "${credentials[@]}" http://127.0.0.1:4003/v1/atlas/current | jq -r .generation)" +curl -fsS -X POST -D manifest.headers "${credentials[@]}" \ "http://127.0.0.1:4003/v1/atlas/generation/${generation}/manifest" | jq authority="$(tr -d '\r' < manifest.headers | awk 'tolower($1) == "atlas-authority:" { print $2 }')" -curl -fS -X POST \ - -H "X-Authenticated-User-Actor-Id: ${actor}" \ +curl -fS -X POST "${credentials[@]}" \ -H "Atlas-Authority: ${authority}" \ "http://127.0.0.1:4003/v1/atlas/tile/${generation}/plain/0/0/0" \ --output root.saltile ``` -Without the actor header a request answers `missing-actor` (400). A data request presenting no live token for that actor answers `unauthorized` (401). +A request carrying no recognized credential answers `unauthenticated` (401), and so does the service credential presented without an actor header, with the nil UUID, or with an actor the principal store does not know. A malformed actor header answers 400 instead, the one distinction the credential path draws by shape rather than by outcome. A data request presenting no live token for that actor answers `unauthorized` (401). ## Concepts -- A **generation** is one fitted, published map: a directory of binary artifacts named by the SHA-256 of its metadata document. Generations never change after publication. New data means a new generation. +- A **generation** is one fitted, published map: a directory of binary artifacts named by the SHA-256 of its metadata document. The directory never changes after publication. What does change is the in-memory publication a serving process keeps over it - entities that arrived since the fit, withdrawals, relabellings - which belongs to that process and is never written back. New fitted structure means a new generation. - A **variant** is one layout of a generation. Version 1 publishes exactly one, named `plain`. -- A **row id** identifies a node row within one generation; edges carry their link entity's raw 32-byte identity instead. On the wire, row ids are opaque values issued through a keyed permutation of the full u32 range. One generation's ids stay consistent across every endpoint and are never bounded by that generation's row count. They do not stay stable across generations, so clients re-translate after a generation change. The permutation's design target is that ids carry no ordering, adjacency, or count information; that hiding is the construction's target, not a demonstrated boundary. Treat ids as meaningless handles either way. -- **Tiles** quadtree the map. Each fitted point carries an importance bucket, and a tile at zoom `z` delivers exactly the points whose bucket clears the zoom's cut - deeper zooms deliver less important points. The manifest's `bucketSchedule` publishes the schedule, so delivery is a pure function of `(generation, z, x, y)`. +- A **row id** identifies a node row within one generation; edges carry their link entity's raw 32-byte identity instead. On the wire, row ids are opaque values issued through a keyed permutation of the full u32 range. One generation's ids stay consistent across every endpoint and are never bounded by that generation's row count. They do not stay stable across generations, so clients re-translate after a generation change. The permutation's design target is that ids carry no ordering, adjacency, or count information. That hiding is the construction's target, not a demonstrated boundary. Treat ids as meaningless handles either way. +- **Tiles** quadtree the map. Each fitted point carries an importance bucket, and a tile at zoom `z` delivers exactly the points whose bucket clears the zoom's cut - deeper zooms deliver less important points. The manifest's `bucketSchedule` publishes the schedule. Delivery is a function of the address `(generation, z, x, y)` together with the serving state the request captured - the caller's resolved visibility scope, which carries a delivery schedule, a density offset and the delta state it resolved against. Fix those and the same address delivers the same points. A second caller whose scope differs can receive different ones. - The **manifest** is the per-generation bootstrap read. It carries the wire version, the variant names, the bucket schedule, and `limits` - request limits published as data, each read from the value its handler enforces. Everything a client needs before its first tile. -The serving read path is `current` (which generation?) then `manifest` (how does it speak?) then tiles, edges, locate, and translate - geometry and configuration pinned per generation, detail trailers hydrated live. +The serving read path is `current` (which generation?) then `manifest` (how does it speak?) then tiles, edges, locate, and translate - configuration pinned per generation and geometry pinned per generation and resolved scope, while a detail trailer reads either the publication the request captured (a tile's labels and icons) or the live store (an edge's type reference, a located entity's types and properties). ## The serving surface | Route | Method | Answer | | --------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------- | | `/status` | GET | process liveness | -| `/v1/atlas/current` | GET | the served generation id - the one mutable read | +| `/v1/atlas/current` | GET | the currently promoted generation id | | `/v1/atlas/generation/{generation}/manifest` | POST | wire version, variants, bucket schedule, this caller's delivery schedule, enforced limits | | `/v1/atlas/tile/{generation}/{variant}/{z}/{x}/{y}` | POST | one tile: positions, row ids, optional type masks and detail trailer | | `/v1/atlas/edges/{generation}/{variant}` | POST | the edges among the listed tiles' delivered rows | @@ -94,28 +99,40 @@ The serving read path is `current` (which generation?) then `manifest` (how does The API documents itself - the OpenAPI reference is the authoritative per-route contract. The notes below are the semantics that span routes. -Binary responses are `application/vnd.hash.saltile-v1` envelopes with `Cache-Control: private, no-store`: the client's application-layer cache is the cache, keyed by authorization context, generation, route, and canonical query. Identical requests yield identical geometry bytes, per generation, server secret, and serving limits; detailed responses hydrate their trailers live from the store and leave the immutable cache - cache the geometry surfaces, refetch detail. The manifest is `no-store` too. Each of its responses mints one caller's authority token and states that caller's own delivery schedule, so a shared copy would hand a second caller both. +Binary responses are `application/vnd.hash.saltile-v1` envelopes with `Cache-Control: private, no-store`: the client's application-layer cache is the cache, keyed by authorization context, generation, route, and canonical query. Identical requests yield identical geometry bytes wherever the bound serving state is identical. That state covers the generation, the server secret and the serving limits, and it covers the caller's resolved scope with the schedule, density offset and delta state that scope carries. That state is not pinned by the generation alone: a scope re-resolves against the process's current delta lifetime, so placements and withdrawals admitted since the last resolution can move geometry for one unchanged address. A detail trailer has no such guarantee, for two separate reasons. Locate and edges read part of their detail from the live store at request time. And every captured label follows the publication the caller's scope resolved against, which moves when that scope re-resolves. Even a tile trailer, reading no store at all, can therefore differ between two identical requests. Cache the geometry surfaces and refetch detail. The manifest is `no-store` too. Each of its responses issues one caller's authority token and states that caller's own delivery schedule, so a shared copy would hand a second caller both. + +Atlas handlers and extractors report failures as RFC 9457 `application/problem+json` documents whose `type` is a stable root-relative URI (`/problems/atlas/unknown-generation`, `/problems/atlas/invalid-coordinate`, ...). Required JSON-body extraction answers `missing-body` when Content-Type is absent, without inspecting body bytes. JSON extraction failures answer `invalid-body`, including an empty body with a JSON content type. An unparsable tile address answers `invalid-coordinate`. + +The manifest reads raw bytes to preserve the exact filter input for its digest, including surrounding whitespace. It accepts these bytes without checking Content-Type. Its body-buffering failures remain plain-text 400 or 413 responses. The router answers unmatched routes and wrong methods with empty-body 404 and 405 responses respectively. -Rejections from the handlers are RFC 9457 `application/problem+json` documents whose `type` is a stable root-relative URI (`/problems/atlas/unknown-generation`, `/problems/atlas/invalid-coordinate`, ...). Extraction failures answer problem documents too. An absent required body answers `missing-body`. A body that is not the operation's JSON shape answers `invalid-body`, and an unparsable tile address answers `invalid-coordinate`. Only the router's own rejections - an unmatched route, a wrong method - stay plain. `unknown-generation` means the route names a generation this process does not serve: re-read `current` and retry. Entities that do not exist and entities the caller may not see answer byte-identically - existence is never disclosed through an error shape. +`unknown-generation` means the route names a generation this process does not serve: re-read `current` and retry. Entities that do not exist and entities the caller may not see answer byte-identically - existence is never disclosed through an error shape. ### Server configuration -Flags have environment fallbacks, and absent flags read documented defaults. Each published manifest limit reads the value its handler enforces (one source, so an advertised limit never disagrees with enforcement); the manifest does not publish every limit - the edge-count truncation limit (`--edges`) shapes responses without a manifest row: +Flags have environment fallbacks, and absent flags read documented defaults. Each limit flag sets the value its handler enforces and the value the manifest publishes under `limits`, one source, so an advertised limit never disagrees with enforcement. One flag can set more than one published limit: `--colored-type-ids` is the ceiling for `limits.tile.coloredTypeIds` and `limits.locate.coloredTypeIds` alike, because raising it means raising the ceiling on colored ids rather than one route's share of it: -| Flag | Environment | Default | Meaning | -| ------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------- | ------------------------------------------------------------------------------------------------ | -| `--root` | `HASH_GRAPH_ATLAS_ROOT` | **required** | the generation root | -| `--atlas-host` | `HASH_GRAPH_ATLAS_HOST` | `127.0.0.1` | listener address | -| `--atlas-port` | `HASH_GRAPH_ATLAS_PORT` | `4003` | listener port | -| `--user`, `--password`, `--host`, `--port`, `--database` | `HASH_GRAPH_PG_*` | local dev store | the store connection; detail trailers hydrate from it live | -| `--secret` | `HASH_GRAPH_ATLAS_SECRET` | **required** | server secret behind the wire row-id codec: 64 lowercase hex characters (`openssl rand -hex 32`) | -| `--colored-type-ids`, `--edges-tiles`, `--edges`, `--translate-entity-ids`, `--locate-edges`, `--locate-properties` | `HASH_GRAPH_ATLAS_LIMIT_*` | documented defaults | serving limits (request validation and response shaping) | +| Flag | Environment | Default | Meaning | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- | +| `--root` | `HASH_GRAPH_ATLAS_ROOT` | **required** | the generation root | +| `--atlas-host` | `HASH_GRAPH_ATLAS_HOST` | `127.0.0.1` | listener address | +| `--atlas-port` | `HASH_GRAPH_ATLAS_PORT` | `4003` | listener port | +| `--user`, `--password`, `--host`, `--port`, `--database` | `HASH_GRAPH_PG_*` | local dev store | the store connection; detail trailers hydrate from it live | +| `--secret` | `HASH_GRAPH_ATLAS_SECRET` | **required** | server secret behind the wire row-id codec: 64 lowercase hex characters (`openssl rand -hex 32`) | +| `--service-secret` | `HASH_GRAPH_SERVICE_SECRET` | **required** | the secret an internal service presents as `Authorization: HASH-Service`, beside the actor header | +| `--colored-type-ids`, `--edges-tiles`, `--edges`, `--translate-entity-ids`, `--locate-edges`, `--locate-properties`, `--locate-link-type-ids`, `--locate-link-properties` | `HASH_GRAPH_ATLAS_LIMIT_*` | documented defaults | serving limits (request validation and response shaping) | +| `--generation-poll-interval` | `HASH_GRAPH_ATLAS_GENERATION_POLL_INTERVAL` | `1` (seconds) | how often maintenance re-reads `current` and advances its generation slots | +| `--unlink-expired-generations` | `HASH_GRAPH_ATLAS_UNLINK_EXPIRED_GENERATIONS` | off | remove an expired generation's directory once its feeds have joined | +| `--no-delta` | `HASH_GRAPH_ATLAS_NO_DELTA` | off | serve fit-time data alone, starting no ingest feed | -Startup is fail-closed. A missing or malformed wire secret, no activated generation, any artifact failing validation (shape, integrity, or identity tables whose keys are not store identities), or an unreachable store all refuse to serve. `ctrl-c` drains in-flight requests and stops the server. +The delta flags - `--delta-poll-interval`, `--delta-safety-lag`, `--delta-retry-polls`, `--delta-placement-backlog`, `--delta-minimum-projection-interval` - tune the ingest feed's cadence, its read-behind window, and its placement backlog. `hash-graph atlas serve --help` lists every flag with the default serving reads for it. + +Startup is fail-closed where it can be. Argument parsing refuses a missing or malformed wire secret. The host then validates its session-authentication configuration and constructs the store pool and the optional Temporal client, and only then builds the serving router, which refuses an entropy failure or invalid maintenance settings. Each of those refuses the invocation before the listener binds. + +What depends on the root is the maintenance loop's business instead. No activated generation, or an artifact failing validation (shape, integrity, or identity tables whose keys are not store identities), leaves the listener up and the open retried at the maintenance cadence. What a read route answers then depends on what maintenance has published. Before the first promotion nothing is active and the read routes answer 503 `visibility-unavailable`. After one, a failed open leaves the active publication active and starts no retention clock. A request naming that generation still reads it, and a request naming none reads it too. Retention is the other case, and it starts when maintenance promotes a different generation - it bounds how long a replaced generation stays selectable by name. A store that will not answer refuses a manifest that needs a new scope resolution with the same 503 `visibility-unavailable`, because a new resolution cannot complete without reading the store. A request whose held scope is still reusable answers without reading it, and a detached refresh that fails leaves the held scope in place. Authentication and the request budgets refuse before the process selects any generation. `ctrl-c` drains in-flight requests and stops the server. A second one forces the exit. ### The compose stack -The `atlas` service in `infra/compose/compose.yml` serves the repository's `var/atlas-generations` directory (bind-mounted, gitignored) against the stack's `postgres`. Fitting stays outside the stack: run `hash-graph atlas fit --root var/atlas-generations ...` on the host and the service serves the activated generation from the shared directory. Serving and fitting never combine implicitly - a serve refuses an empty root rather than fitting one, and the service reports unhealthy until a generation exists. No other service waits on it. +The `atlas` service in `infra/compose/compose.yml` runs `atlas serve` over the repository's `var/atlas-generations` directory (bind-mounted, gitignored) against the stack's `postgres`, with the stack's Kratos and service secret for credentials. Fitting stays outside the stack: run `hash-graph atlas fit --root var/atlas-generations ...` on the host, and the service picks the activation up on its next maintenance pass. Serving and fitting never combine implicitly - a serve over an empty root serves nothing rather than fitting one. Its healthcheck probes `/status`. That route answers as soon as the listener is up, so the service reports healthy before any generation exists, while the read routes still answer 503. No other service waits on it. ## Storage model @@ -129,38 +146,39 @@ A generation id is the SHA-256 of its metadata document: ``` -Generation directories are immutable and publication is no-clobber. A publish whose metadata document already has its directory fails and leaves that directory untouched, so re-publishing an identical generation is a reported error rather than a silent no-op. The id is the SHA-256 of the metadata document. That document names every artifact's content hash, so a change to any artifact's bytes yields a different id and its own directory. Readers open artifacts by role through the metadata, never by guessing file names. Activation is an atomic rename of `current`; a serving process resolves the pointer once at startup, so activation changes take effect on the next start. Back up a pointer together with its generation directory - a pointer without its generation is not recoverable state. +Generation directories are immutable and publication is no-clobber. A publish whose metadata document already has its directory fails and leaves that directory untouched, so re-publishing an identical generation is a reported error rather than a silent no-op. The id is the SHA-256 of the metadata document. That document names every artifact's content hash, so a change to any artifact's bytes yields a different id and its own directory. Readers open artifacts by role through the metadata, never by guessing file names. Activation is an atomic rename of `current`, and a serving process re-reads that pointer on every maintenance pass: an activation takes effect on the first pass that opens the generation it names, without a restart. The generation it replaces stays requestable by name for the retention interval that starts at the replacement's promotion - ten minutes, as the `hash-graph` binary configures it - after which naming it answers `unknown-generation`. Back up a pointer together with its generation directory - a pointer without its generation is not recoverable state. ## Security posture -The API serves only reads, and the trust boundary runs through the surrounding service rather than through this crate. **Atlas does not authenticate a user.** It trusts `X-Authenticated-User-Actor-Id` as stated by whatever fronts it. What it authenticates is token continuity: a data request replays the `Atlas-Authority` token its manifest response minted, and that token's tag binds the actor presenting it. One visibility scope resolves per actor. Authenticating the user, and overwriting any actor header the caller supplied, belongs to the surrounding service, which in this repository is hash-api's `/atlas` proxy. Exposing this port directly therefore hands actor identity, and every scope with it, to the caller. Bind it to loopback or a trusted internal network and put TLS and rate limits in front of it. +The API serves only reads, and it authenticates every atlas route it serves - `/status`, the liveness probe described above, sits outside that chain and is the only route that does. The `hash-graph` binary installs the same credential chain its REST API uses: a Kratos session first, then the service credential `Authorization: HASH-Service ` carrying a delegated actor in `X-Authenticated-User-Actor-Id`. An actor header without that credential is not a credential, and a request that presents none answers `unauthenticated` - the atlas routes admit no anonymous caller. Address and principal request budgets run in the same stack of layers. Token continuity is a second check on top of that authentication. A data request replays the `Atlas-Authority` token its manifest response issued, and that token's sealed scope binds the actor presenting it. A resolved visibility scope binds the actor, generation, delta lifetime and requested filter. + +The end-user leg still belongs to the surrounding service, which in this repository is hash-api's `/atlas` proxy. It authenticates the browser session and decides which actor an internal service may delegate for. It also holds the service secret. That secret is the boundary, because anyone holding it can name any actor and read that actor's whole scope. Keep it in a deployment secret store. Bind the port to loopback or a trusted internal network, and terminate TLS in front of it - this process speaks plain HTTP. What the crate does guarantee, independent of the surrounding service: -- Row ids cross the wire through a keyed permutation derived from the server secret (`HASH_GRAPH_ATLAS_SECRET`) per generation. The permutation's design target is that id values and response orders carry no information about internal row assignment; that hiding is the construction's target, not a demonstrated boundary. The secret is mandatory - the server refuses to start without one - and comes from a deployment secret store. Replicas serving one generation share it. +- Row ids cross the wire through a keyed permutation derived from the server secret (`HASH_GRAPH_ATLAS_SECRET`) per generation. The permutation's design target is that id values reveal nothing about internal row assignment. That hiding is the construction's target, not a demonstrated boundary. The secret is mandatory - the server refuses to start without one - and comes from a deployment secret store. Replicas serving one generation share it. - Missing and forbidden answer byte-identically on every id-bearing route. - Published manifest limits and their handler enforcement read the same value, by construction. - A server-held visibility proof governs every corpus-bearing response (tile, edges, locate, translate). The proof carries one mask per identity domain, so a link row's authorization is a statement the proof holds and its endpoints do not imply. Hidden rows are indistinguishable from nonexistent ones on every id-bearing route. A manifest request resolves the caller's scope and seals it into the authority token the data routes require. ## Limitations -- Each process serves one generation, pinning `current` at startup and never hot-swapping. Restart to serve a newly activated generation. -- The server rejects `filter` fields rather than ignoring them: request bodies deny unknown members at parse and answer `invalid-body`. The specification defines the filter surface, and no handler serves it. -- Row ids do not survive a refit. A client persists anything it keeps in entity-identity terms and re-translates it per generation. +- The filter surface binds at the manifest and nowhere else. The manifest body is the entity-query filter document. The data routes' bodies deny unknown members at parse, so a `filter` member there answers `invalid-body` rather than passing unread. +- Row ids do not survive a refit, and a fed-in entity's row id belongs to the delta lifetime that assigned it. A client persists anything it keeps in entity-identity terms and re-translates it per generation, and again after a refused token. - The server secret keys wire ids per generation, and nothing fingerprints the secret. Changing it for an already-served generation re-keys every wire id under unchanged cache identity. Treat the secret as immutable per generation, and rotate generations to rotate secrets. - Output-affecting serving limits (edge truncation, locate limits) are the same class of operator contract. Nothing fingerprints them, so keep them stable while a generation serves, or rotate the generation and clear application caches. -- Incremental ingestion stays off. New entities enter through the next fit. -- The fit pipeline requires a live HASH Graph PostgreSQL store. No offline corpus format exists. +- Ingest feeds carry post-fit arrivals, withdrawals and relabellings into the served map, and `--no-delta` turns them off. They do not refit. The frozen projector places a fed-in entity into the existing frame, while the landmarks and the relation structure stay as the fit left them, and a fed-in node takes its delivery priority from identity order rather than from a fitted rank. New fitted structure still means a new generation. +- The `hash-graph` binary's fit path requires a live HASH Graph PostgreSQL store. An offline corpus format does exist - `atlas dump` writes a dump directory and `--offline` fits from one - but it lives in the standalone `hash-graph-atlas` binary behind the `cli` feature. ## Crate layout Domain-independent foundations with the SALT pipeline on top: -- `math`, `random`, `bitset`, `integrity`, `morton` - SIMD-native 2D geometry and kernels, unbiased sampling, dense row sets, SHA-256 content identity, Z-order keys. +- `math`, `random`, `bitset`, `integrity`, `morton` - SIMD-native 2D geometry and kernels, bounded and subset sampling, dense row sets, SHA-256 content identity, Z-order keys. - `file` - the on-disk artifact formats: plain files in a directory, described by metadata beside them. - `dataset` - the data one fit runs over, wherever it lives. - `salt` - the pipeline, covering graph construction, landmark layout, projector training, evaluation, and wire encoding. -- `cli`, `progress` - the operator seam: the commands that fit a generation and serve the atlas, and the observations a running fit reports. +- `cli`, `progress` - the operator's entry points: the commands that fit a generation and serve the atlas, and the observations a running fit reports. - `serve` - opened generations answering reads as wire bytes. ## Development @@ -171,16 +189,16 @@ cargo test --package hash-graph-atlas --doc cargo clippy --all-features --package hash-graph-atlas ``` -Tests never require a GPU or a live store; fixture fits run the production pipeline end to end on synthetic corpora, and the fixtures under `fixtures/wire/` pin the wire formats. +Tests never require a GPU or a live store. Fixture fits run the production pipeline end to end on synthetic corpora, and the fixtures under `fixtures/wire/` pin the wire formats. Cargo features (all off by default): - `bench` - exposes the benchmark hooks the `[[bench]]` targets consume. The projector backend target measures the CPU and host-derived accelerator, requiring Metal on macOS or CUDA elsewhere. -- `cli` - builds the standalone `hash-graph-atlas` binary: the fit path with its live dashboard, and the lab instruments under `report`. +- `cli` - builds the standalone `hash-graph-atlas` binary. It carries the fit path with its live dashboard, the `dump` command and the `--offline` fit that reads what `dump` wrote, and the measurement commands under `report`. -The operator commands (`cli` module) and the read API (`api` module) build unconditionally, so the `hash-graph` binary consumes them feature-free. The feature gates only the standalone binary's shell. +The operator commands (`cli` module) and the read API (`api` module) build unconditionally, so the `hash-graph` binary consumes them feature-free. The feature covers the standalone binary's shell alone. -The lab instruments read published artifacts and print their readings - the clump-threshold calibration, the neighbour-construction audits, the search-backend sweep, the certified classifier refit, the fold probe, and one live quality assessment: +The measurement commands read published artifacts and print their readings - the clump-threshold calibration, the neighbour-construction audits, the search-backend sweep, the certified classifier refit, the fold probe, and one live quality assessment: ```sh cargo run -p hash-graph-atlas --features cli --release -- \ @@ -196,7 +214,7 @@ rows 985932 neighbours 30 0.0020 566791 131760 550901 55.9% 4.18 ``` -Every instrument's defaults are the deployed settings, so a bare invocation re-derives the evidence behind a configured default. `hash-graph-atlas report --help` lists them. Serving stays exclusive to the `hash-graph` binary. +Every such command's defaults are the deployed settings, so a bare invocation re-derives the evidence behind a configured default. `hash-graph-atlas report --help` lists them. Serving stays exclusive to the `hash-graph` binary. ## License diff --git a/libs/@local/graph/atlas/benches/backfill_walk.rs b/libs/@local/graph/atlas/benches/backfill_walk.rs index 8d1fc0eecfd..46d1c9b1356 100644 --- a/libs/@local/graph/atlas/benches/backfill_walk.rs +++ b/libs/@local/graph/atlas/benches/backfill_walk.rs @@ -371,7 +371,7 @@ const RULES: [FillRule; 4] = [ /// This panics when `z` exceeds the key width or `(x, y)` lies off the zoom's grid. const fn cell_of(z: u8, x: u32, y: u32) -> MortonCell { MortonCell::new( - Depth::new(z).expect("tile zooms lie within the key width"), + Depth::try_new(z).expect("tile zooms lie within the key width"), x, y, ) @@ -733,8 +733,8 @@ fn pyramid_cost(scales: &[usize]) { black_box(bench.visible_cascade(VisibleRankOrder::Base)); }); let shallowest = - Depth::new(bench.span()).expect("the span lies within the key width"); - let middle = Depth::new(bench.span() + bench.max_zoom() / 2) + Depth::try_new(bench.span()).expect("the span lies within the key width"); + let middle = Depth::try_new(bench.span() + bench.max_zoom() / 2) .expect("cut depths lie within the key width"); println!( @@ -790,7 +790,7 @@ fn query_cost(bench: &mut WalkBench, path: &[(u8, u32, u32)]) { } let cell = cell_of(z, x, y); let cut = - Depth::new(z + bench.span()).expect("cut depths lie within the key width"); + Depth::try_new(z + bench.span()).expect("cut depths lie within the key width"); let batch_micros = median_micros(|| { for _ in 0..BATCH { black_box(pyramid.count(black_box(cell), black_box(cut))); @@ -1367,7 +1367,7 @@ fn served_density(bench: &mut WalkBench, tiles: &[(u8, u32, u32)], rules: &[Fill continue; } - let cut = Depth::new(z + bench.span()).expect("a valid cut"); + let cut = Depth::try_new(z + bench.span()).expect("a valid cut"); let shown: HashSet = bench .served_cumulative_delivery(rule, z, x, y, &generation) .iter() @@ -1588,7 +1588,7 @@ fn served_breakdown(bench: &mut WalkBench, path: &[(u8, u32, u32)]) { if !z.is_multiple_of(6) { continue; } - let cut = Depth::new(z + bench.span()).expect("a valid cut"); + let cut = Depth::try_new(z + bench.span()).expect("a valid cut"); let cells = bench .served_representatives(z, x, y, cut, &generation) .len(); @@ -2676,7 +2676,7 @@ fn rule_density_audit( if z == 3 { dots_at_three = delivered.len(); } - let window_depth = Depth::new(z + 2).expect("the audit windows fit the key"); + let window_depth = Depth::try_new(z + 2).expect("the audit windows fit the key"); let shown = delivered_window_counts(codes, &delivered, window_depth); let fit = best_density_fit(bench, &shown, z, window_depth); diff --git a/libs/@local/graph/atlas/benches/miner_index.rs b/libs/@local/graph/atlas/benches/miner_index.rs index 8a7f3c9ccb5..eb10a623a86 100644 --- a/libs/@local/graph/atlas/benches/miner_index.rs +++ b/libs/@local/graph/atlas/benches/miner_index.rs @@ -54,7 +54,7 @@ clippy::integer_division, clippy::integer_division_remainder_used, reason = "fixture synthesis and grid geometry cast, divide, and index between counts and \ - coordinates in domains the fixture construction bounds; the crate-level \ + coordinates in domains the fixture construction bounds. The crate-level \ expectations in lib.rs do not extend to bench targets" )] #![expect( diff --git a/libs/@local/graph/atlas/docs/wire.md b/libs/@local/graph/atlas/docs/wire.md index d33f1111ced..8e87a9c67e7 100644 --- a/libs/@local/graph/atlas/docs/wire.md +++ b/libs/@local/graph/atlas/docs/wire.md @@ -4,11 +4,13 @@ The binary contract for every atlas geometry response: tile, edges, and locate. ## 1. Scope -One envelope family carries every binary response. The manifest stays JSON: read once per session, human-debuggable, and it carries the `wireVersion` pin that governs everything else. Its optional POST body is the entity-query filter document that binds the view. Data-route bodies carry no `filter` member. Transport compression is HTTP `Content-Encoding`. The envelope is compression-agnostic. +One envelope family carries every binary response. The manifest stays JSON: human-debuggable, and it carries the `wireVersion` pin that governs everything else. It is the bootstrap read, not a once-per-session read - a client re-reads it to renew its authority token before the token's hard expiry, and again after an `unknown-generation` or `unauthorized` answer. Its optional POST body is the entity-query filter document that binds the view. Data-route bodies carry no `filter` member. Transport compression is HTTP `Content-Encoding`. The envelope is compression-agnostic. ## 2. Envelope -Responses carry `application/vnd.hash.saltile-v1`. The media-type version and the prefix `wireVersion` must agree. All integers are little-endian. One response = prefix, directory, payloads, optional trailer: +The wire format uses the vendor-specific media type `application/vnd.hash.saltile-v1` ([RFC 6838, Section 3.2](https://www.rfc-editor.org/rfc/rfc6838.html#section-3.2)). The version number in the media type must match `wireVersion` in both the manifest response and the envelope prefix. Outside CBOR payloads, all multi-byte numeric values use little-endian encoding. + +One response = prefix, directory, payloads, optional trailer: | Region | Size | Contents | | --------- | --------------- | ------------------------------------------------------------------------------------------------------------------------ | @@ -47,9 +49,9 @@ The offset directory is the locating mechanism: one fixed lookup finds any secti - Offsets are u32: directory-addressed payloads end below 4 GiB, the format's representability boundary, enforced by the producer (section 8a). The trailer is directory-external and outside that ceiling. The deepest tile's catch-all geometry is the uncapped directory case. - Prefix `flags` and `reserved` must be zero. - Payload order equals slot order, and prefix, directory, `HEAD`, and columns are pure functions of the bound serving state `(generation, request, visibility, server secret, serving limits)` - identical requests under identical state yield byte-identical bytes there, the property the client's application-layer cache keys on. Secret and limits are restart-stable by operator contract - changing either for an active generation requires rotating the generation and clearing application caches - which is why the cache key carries neither. Tile icons are also generation-local and byte-identical under that state. Trailer labels are not, on any detail route. A fitted identity's and a published link's label reads from the captured display held by the request's resolved scope - normally the entity's currently served edition, an earlier one while the scope's pinned publication state predates the newest capture or while the server's capture read has not answered for it. A fitted identity the scope holds no capture for reads the generation's own payload (section 7), and a post-fit published point carries the display recorded at its placement (the next bullet). The server pins publication state per scope when it resolves the scope's visibility, and re-resolves on an operator-configured reuse window - the manifest's `limits.authorityRefreshSeconds` and `limits.authorityHardSeconds`, eight and ten minutes by default. A label edit after publication therefore reaches a scope's responses at that scope's next resolution, not at the server's next capture. A fitted identity's or published link's served label may lag the newest edition by the ingest cadence - up to 65 seconds (section 7) - plus the remainder of the scope's reuse window. Tile, edges, and locate move together within one scope, and two scopes resolved at different times may serve different labels for the same entity at the same moment. On edges, a link's type reference reads its current edition's representative type the same way, under the same bound. Locate type and property values - link type references included - hydrate from the live store. The store is bitemporal and an edition is immutable, so a lagging value reads as the recent past, never as a rewrite. Detailed trailer bytes on any route may therefore differ between identical requests as scopes re-resolve, store-derived values change, or entities stop or start resolving. A client must not retain a detailed response as an immutable generation tile. Cache geometry sections and refetch detail where request-time state matters. -- The serving state includes the server's **post-fit publications**: entities that enter the store after the generation's fit and that the server publishes into serving between refits. Section 8 calls a link published this way a delta link. Published entities ride the ordinary columns of every route that delivers them, so a `ROW_IDS` value may address an entity the fit never saw. Nothing in the id says so - publication ids come from the same keyed permutation as the generation's own rows (section 5). A published point's `TYPE_MASK` bits read zero whatever the request's `coloredTypeIds` lists, and its tile icon resolves through its representative type (section 6). Its label is the display captured at placement, which no later edition moves: the label stays fixed until the next refit, even as locate's store-hydrated type and property columns move with the entity. A published link's label follows the captured-display rule above and revises with the entity. Publications reach a scope at the scope's next resolution, while a withdrawal takes effect within the ingest cadence on every route - between resolutions a scope's served set never grows past what the resolution admitted. Unarchive lifts the withdrawal within the same cadence, and that ceiling decides what returns. A row the resolution admitted resumes serving once the withdrawal lifts. A row outside what the resolution admitted returns only at the scope's next resolution, whether a restricted scope's resolution folded it out of its masks or the withdrawal predates the scope's pinned publication state. An operator view folds nothing out, so a fitted row's withdrawal and its reversal both land within the cadence. +- The serving state includes the server's **post-fit publications**: entities that enter the store after the generation's fit and that the server publishes into serving between refits. Section 8 calls a link published this way a delta link. Published entities appear in the ordinary columns of every route that delivers them, so a `ROW_IDS` value may address an entity the fit never saw. Nothing in the id says so - publication ids come from the same keyed permutation as the generation's own rows (section 5). A published point's `TYPE_MASK` bits read zero whatever the request's `coloredTypeIds` lists, and its tile icon resolves through its representative type (section 6). Its label is the display captured at placement, which no later edition moves: the label stays fixed until the next refit, even as locate's store-hydrated type and property columns move with the entity. A published link's label follows the captured-display rule above and revises with the entity. Publications reach a scope at the scope's next resolution, while a withdrawal takes effect within the ingest cadence on every route - between resolutions a scope's served set never grows past what the resolution admitted. Unarchive lifts the withdrawal within the same cadence, and that ceiling decides what returns. A row the resolution admitted resumes serving once the withdrawal lifts. A row outside what the resolution admitted returns only at the scope's next resolution, whether a restricted scope's resolution folded it out of its masks or the withdrawal predates the scope's pinned publication state. An operator view folds nothing out, so a fitted row's withdrawal and its reversal both take effect within the cadence. -The directory declares every column's extent before the first payload byte, so prefix + directory stream first and columns follow immediately. The trailer lives outside the directory as a self-delimiting CBOR tail - declared by a `HEAD` key on tile and edges, mandated by kind on locate - whose start is `align8` of the last present column's end and whose extent is its own CBOR structure. Edges and locate trailers arrive only after their request-time store reads. Labels themselves never ride that read on any route - they resolve in process, from the label sources the freshness bullet above names. The tile trailer needs no store read. The layout is the streaming contract; current servers assemble the whole body before the first byte, so an edges or locate response's first-byte latency includes hydration. A streaming decoder is correct against both. Geometry sections decode from a partial body, and on successful responses a future streaming server changes delivery timing, never bytes. If edges hydration fails after a streaming server has sent the columns, the trailer arrives valid and empty-shaped: the in-process labels remain, while the type table is empty and type references are null. If locate hydration fails at that point, its empty-shaped trailer has null labels and values alongside empty tables, with every completeness bit unset. Neither route sends a truncated body. Problem documents cover only failures before the first body byte. +The directory declares every column's extent before the first payload byte, so prefix + directory stream first and columns follow immediately. The trailer lives outside the directory as a self-delimiting CBOR tail - declared by a `HEAD` key on tile and edges, mandated by kind on locate - whose start is `align8` of the last present column's end and whose extent is its own CBOR structure. Edges and locate trailers arrive only after their request-time store reads. Labels themselves are never part of that read on any route - they resolve in process, from the label sources the freshness bullet above names. The tile trailer needs no store read. The layout is the streaming contract; current servers assemble the whole body before the first byte, so an edges or locate response's first-byte latency includes hydration. A streaming decoder is correct against both. Geometry sections decode from a partial body, and on successful responses a future streaming server changes delivery timing, never bytes. If edges hydration fails after a streaming server has sent the columns, the trailer arrives valid and empty-shaped: the in-process labels remain, while the type table is empty and type references are null. If locate hydration fails at that point, its empty-shaped trailer has null labels and values alongside empty tables, with every completeness bit unset. Neither route sends a truncated body. Problem documents cover only failures before the first body byte. ## 3. Slot tables @@ -96,7 +98,7 @@ Labels are exempt from property masking and arrive as-is. [BE-313](https://linea `TYPE_MASK` gives point p its bitmask at byte offset `p * ceil(n/8)`: bit i (byte `i >> 3`, bit `i & 7`, LSB-first) = the point carries the request's type i. No-match is the zero mask - no sentinel exists - and multi-typed points carry every matching bit; which color paints, blends, or badges is the client's policy, re-prioritizable without a re-fetch. A mask read as its set-bit indexes is the point's colored-type index list. At n <= 8 the column is one byte per point. -The `coloredTypeIds` entries are user-facing _versioned_ type URLs. A malformed entry rejects the body (`invalid-body`). The server resolves each against the generation's snapshot with descendant expansion. A point matches type i when it carries the requested type or any descendant of it. A well-formed URL that resolves to no type in this generation is legal - it never matches, so its bit reads 0 in every point's mask. A post-fit published point (section 2) carries the zero mask whatever the request lists: matching resolves against the generation's snapshot, which never saw it. `TYPE_MASK` rides only requests that supply `coloredTypeIds` - absent otherwise (directory `(0, 0)`). +The `coloredTypeIds` entries are user-facing _versioned_ type URLs. A malformed entry rejects the body (`invalid-body`). The server resolves each against the generation's snapshot with descendant expansion. A point matches type i when it carries the requested type or any descendant of it. A well-formed URL that resolves to no type in this generation is legal - it never matches, so its bit reads 0 in every point's mask. A post-fit published point (section 2) carries the zero mask whatever the request lists: matching resolves against the generation's snapshot, which never saw it. `TYPE_MASK` is present only on requests that supply `coloredTypeIds` - absent otherwise (directory `(0, 0)`). ## 4. CBOR profile @@ -143,9 +145,9 @@ Restricted views deliver from their own schedule. The server builds a first-occu | 2 | `minResolution` | `uint` | the deepest bucket the visible set occupies: the coarsest cut delivering all of it | - One 32-byte identity echo pins everything: the generation id is sha256 of the generation's metadata document, which carries every artifact digest. The echo exists so a decoder rejects a stale or misrouted cache body before its arrays reach a renderer. -- `complete` does not ride the tile wire: `children == 0` is the completeness signal, in both modes (nothing deeper exists). +- The tile wire has no `complete` field: `children == 0` is the completeness signal, in both modes (nothing deeper exists). - The format reserves `children` bits beyond the low four as zero. A diving client walks exactly the occupied frontier - no empty-tile probes. A cell with no quad node answers `children = 0` (its points, if any, were all delivered by ancestor cuts). Under an active filter the truthful bitmask is post-intersection, like every `global` aggregate. -- No response-level type summary rides `HEAD`: "which requested types are active here" is the bitwise union of the `TYPE_MASK` column, derivable in the same decode pass that colours dots. +- `HEAD` states no response-level type summary: "which requested types are active here" is the bitwise union of the `TYPE_MASK` column, derivable in the same decode pass that colours dots. - Truncation is detectable from the directory alone: the response must extend to `align8(end)` of the last present slot, plus a complete CBOR item when `HEAD` declares the trailer - a stream ending early is an error even without Content-Length. - Every `global` aggregate is a post-intersection quantity (authorization and filter). @@ -170,13 +172,13 @@ Icons come from the generation's published identity artifacts, or from the publi | 3 | `complete` | `bool` | false = the rank-ordered cap truncated the set (auth-invisible edges are not truncation - missing = denied) | | 4 | `trailer` | `bool` | a CBOR trailer tail follows the last column (echoes a `detail: "auxiliary"` request) | -The request's `tiles` list is not echoed. It rides the POST body, responses are `private, no-store`, and the generation echo pins identity. Column extents are `4 x count` for the endpoint columns and `32 x count` for `EDGE_IDS`. Delivery order is ascending `EDGE_IDS` bytes, independent of the tiles listed and of truncation, so identical requests yield identical column bytes under section 2's identity state. All information references the most recent edition of each referenced entity visible to the serving Atlas process; section 2 bounds the lag - the ingest cadence, up to 65 seconds, plus the scope's reuse window for additions and revisions, the cadence alone for removals. A link's label and representative type resolve in process: from the display the scope holds captured at the link's currently served edition, and from the generation's own payload for a fitted link the scope holds no capture for. Section 2 states the caching consequences. The trailer as a whole is outside that identity guarantee (section 2). Every delivered edge has both endpoints in the listed tiles' delivered row sets. Sources and targets reference node row ids the client already holds for those tiles. "Delivered row set" means what the tile route delivers to this view: the corpus schedule's cumulative prefix through `z + span` for an operator view, and the view's own cascade through `z + span + k` for a restricted one. A restricted view's edges are therefore drawn among exactly the dots its own tiles rendered. +The request's `tiles` list is not echoed. The client sends it in the POST body, responses are `private, no-store`, and the generation echo pins identity. Column extents are `4 x count` for the endpoint columns and `32 x count` for `EDGE_IDS`. Delivery order is ascending `EDGE_IDS` bytes, independent of the tiles listed and of truncation, so identical requests yield identical column bytes under section 2's identity state. All information references the most recent edition of each referenced entity visible to the serving Atlas process; section 2 bounds the lag - the ingest cadence, up to 65 seconds, plus the scope's reuse window for additions and revisions, the cadence alone for removals. A link's label and representative type resolve in process: from the display the scope holds captured at the link's currently served edition, and from the generation's own payload for a fitted link the scope holds no capture for. Section 2 states the caching consequences. The trailer as a whole is outside that identity guarantee (section 2). Every delivered edge has both endpoints in the listed tiles' delivered row sets. Sources and targets reference node row ids the client already holds for those tiles. "Delivered row set" means what the tile route delivers to this view: the corpus schedule's cumulative prefix through `z + span` for an operator view, and the view's own cascade through `z + span + k` for a restricted one. A restricted view's edges are therefore drawn among exactly the dots its own tiles rendered. Edges trailer, present iff the request set `detail: "auxiliary"` (edge order): | Key | Name | Type | Meaning | | --- | ------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 | `typeTable` | `[tstr ...]` | the type intern table: every referenced versioned type URL once, bytewise-sorted | +| 0 | `typeTable` | `[tstr ...]` | the type intern table: every referenced versioned type URL once, in any order | | 1 | `linkLabels` | `[tstr or null ...]` | edge order | | 2 | `linkTypeIds` | `[uint or null ...]` | each link's representative type as a typeTable index; `null` marks a representative the store no longer resolves, which a healthy lifecycle never produces | @@ -199,28 +201,28 @@ Edges trailer, present iff the request set `detail: "auxiliary"` (edge order): Delivered node order is source first, then the delivered edges' partners ascending by wire row id. A partner whose every edge truncated is not delivered. The source's row id and position are `ROW_IDS[0]` / `POSITIONS[0]`. No `HEAD` key repeats them. `zoom` is the zoom at which the source's dot first becomes visible to this view - the cut rule inverted over the schedule the view delivers under, `z + span` for an operator view and `z + span + k` for a restricted one. A restricted view's answer is a function of its own visible rows, so it carries no evidence of what its mask removed. `cell` is its tile there. Identical requests yield identical prefix, directory, `HEAD`, and column bytes under section 2's identity state. Trailer labels, admitted by request-time entity resolution, resolve in process: a fitted row reads the scope's pinned captured display first and the generation's payload without one, and a published point reads its placement display. Link labels follow section 7's rule, so a revised link serves the same label here as on edges under the same scope (section 2). Type and property columns reflect live store state. The trailer as a whole is therefore outside that identity guarantee (section 2). The request's `entityId` is not echoed (POST body + `private, no-store` + the generation echo). -Edge columns carry the source's ego graph - every edge incident to the source, both directions, a self-loop exactly once, its other endpoint visible - ascending `EDGE_IDS` bytes, capped by `limits.locateEdges`. Fitted edges and the server's published delta links qualify alike, under one cap and one order, so the ego graph spans both serving domains whatever domain the source resolves in. Truncation keeps the edges whose partners lie nearest the source, ascending (squared wire-frame distance to the partner, partner first-visible zoom, `EDGE_IDS` bytes). The key only selects - presentation stays ascending `EDGE_IDS` bytes - and `HEAD` reports `complete: false`. `edges: 0` with `complete: true` is the correct answer for an unlinked source: the ego-graph of an isolated dot is the dot. +Edge columns carry the source's ego graph - every edge incident to the source, both directions, a self-loop exactly once, its other endpoint visible - ascending `EDGE_IDS` bytes, capped by `limits.locate.edges`. Fitted edges and the server's published delta links qualify alike, under one cap and one order, so the ego graph spans both serving domains whatever domain the source resolves in. Truncation keeps the edges whose partners lie nearest the source, ascending (squared wire-frame distance to the partner, partner first-visible zoom, `EDGE_IDS` bytes). The key only selects - presentation stays ascending `EDGE_IDS` bytes - and `HEAD` reports `complete: false`. `edges: 0` with `complete: true` is the correct answer for an unlinked source: the ego-graph of an isolated dot is the dot. Locate trailer, always present - locate is the detail view. Its labels resolve in process. A fitted row reads the scope's pinned captured display first, falling back to the generation's payload, and a published point reads its placement display. They read null when the request-time store does not resolve the corresponding entity or when the label its source carries is empty. The intern tables first, then node arrays in delivered order and link arrays in edge order, every type and property reference a uint index into its table: -| Key | Name | Type | Meaning | -| --- | ------------------------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 | `typeTable` | `[tstr ...]` | every referenced versioned type URL once, bytewise-sorted | -| 1 | `propertyTable` | `[tstr ...]` | every surviving property base URL once, bytewise-sorted | -| 2 | `labels` | `[tstr or null ...]` | delivered order | -| 3 | `typeIds` | `[uint or null ...]` | each node's representative type as a typeTable index; null = the store no longer serves the node or records no types | -| 4 | `properties` | `map or null` | the source's property map - propertyTable index -> scalar value, keys ascending, capped by limits.locateProperties (null = store-absent source). Neighbour nodes arrive without properties - their detail is one locate away | -| 5 | `linkLabels` | `[tstr or null ...]` | edge order | -| 6 | `linkTypeIds` | `[[uint ...] ...]` | each link's direct types as typeTable indexes, canonical order preserved, capped by limits.locateLinkTypeIds; empty = store-absent link | -| 7 | `linkTypeIdsComplete` | `bstr` | LSB-first bitmask in whole 8-byte words (`ceil(edges/64) * 8` bytes, padding bits zero), bit e set = edge e's type list is the link's whole direct set; unset = the cap truncated it or the store no longer serves it | -| 8 | `linkProperties` | `[map or null ...]` | edge order - propertyTable index -> scalar value, keys ascending, capped by limits.locateLinkProperties; null = store-absent link | -| 9 | `linkPropertiesComplete` | `bstr` | LSB-first bitmask in whole 8-byte words (`ceil(edges/64) * 8` bytes, padding bits zero), bit e set = edge e's property map is the link entity's whole **deliverable** set | - -Property values are scalar values only (tstr / int / f64 / bool / null) - nested objects and arrays never cross the wire. A number encodes as an integer when the store renders it integral and it fits i64, as a double otherwise. An over-cap entity drops properties reverse-lexicographically by base URL, keeping the request-time edition's label property until last. The property's survival does not change the already-published label payload. Survivors emit ascending by name, which is ascending index order. The intern tables are the unions of every surviving reference, and an index costs less than the string it replaces every time a URL repeats. +| Key | Name | Type | Meaning | +| --- | ------------------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | `typeTable` | `[tstr ...]` | every referenced versioned type URL once, in any order | +| 1 | `propertyTable` | `[tstr ...]` | every surviving property base URL once, in any order | +| 2 | `labels` | `[tstr or null ...]` | delivered order | +| 3 | `typeIds` | `[uint or null ...]` | each node's representative type as a typeTable index; null = the store no longer serves the node or records no types | +| 4 | `properties` | `map or null` | the source's property map - propertyTable index -> scalar value, keys ascending, capped by limits.locate.properties (null = store-absent source). Neighbour nodes arrive without properties - their detail is one locate away | +| 5 | `linkLabels` | `[tstr or null ...]` | edge order | +| 6 | `linkTypeIds` | `[[uint ...] ...]` | each link's direct types as typeTable indexes, canonical order preserved, capped by limits.locate.linkTypeIds; empty = store-absent link | +| 7 | `linkTypeIdsComplete` | `bstr` | LSB-first bitmask in whole 8-byte words (`ceil(edges/64) * 8` bytes, padding bits zero), bit e set = edge e's type list is the link's whole direct set; unset = the cap truncated it or the store no longer serves it | +| 8 | `linkProperties` | `[map or null ...]` | edge order - propertyTable index -> scalar value, keys ascending, capped by limits.locate.linkProperties; null = store-absent link | +| 9 | `linkPropertiesComplete` | `bstr` | LSB-first bitmask in whole 8-byte words (`ceil(edges/64) * 8` bytes, padding bits zero), bit e set = edge e's property map is the link entity's whole **deliverable** set | + +Property values are scalar values only (tstr / int / f64 / bool / null) - nested objects and arrays never cross the wire. A number encodes as an integer when the store renders it integral and it fits i64, as a double otherwise. An over-cap entity drops properties reverse-lexicographically by base URL, keeping the request-time edition's label property until last. The property's survival does not change the already-published label payload. Property maps emit entries in ascending numeric table-index order, independent of property-name order. The intern tables are the unions of every surviving reference, and an index costs less than the string it replaces every time a URL repeats. ## 8a. Problem documents -Routed requests that fail answer RFC 9457 `application/problem+json`; the `type` member is a stable root-relative URI (`/problems/atlas/`) and `detail` is prose, contractually free. Internal failures (500) carry a static `detail` - driver errors and panic payloads are server-log material and never reach a client. Extraction failures are problem documents too. An absent required body answers `missing-body`, a body that is not the operation's JSON shape (malformed JSON, a wrong content type, an entry that fails its field's parse) answers `invalid-body`, and an unparsable tile address answers `invalid-coordinate`. Only the router's own rejections - an unmatched route, a wrong method - stay plain. +Atlas handlers and extractors report failures as RFC 9457 `application/problem+json` documents. The `type` member is a stable root-relative URI (`/problems/atlas/`) and `detail` is prose, contractually free. Internal failures (500) carry a static `detail` - driver errors and panic payloads are server-log material and never reach a client. Required JSON-body extraction answers `missing-body` when Content-Type is absent, without inspecting body bytes. JSON extraction failures answer `invalid-body`, including an empty body with a JSON content type, malformed JSON, an unsupported content type, or an entry that fails its field's parse. An unparsable tile address answers `invalid-coordinate`. The manifest reads raw bytes without checking Content-Type. Its body-buffering failures remain plain-text 400 or 413 responses. An unmatched route or a wrong method also receives the router's plain response. Every route answers under every scope. A proof carries a mask per identity domain, so a link row's authorization is a statement the proof holds and its endpoints do not imply. Refusals are per row, an unproven row is absent, and a scope that may see nothing receives a well-formed response that delivers nothing. Translate's `edges` map holds exactly the link ids whose link row and both endpoints the proof admits. diff --git a/libs/@local/graph/atlas/src/allocator.rs b/libs/@local/graph/atlas/src/allocator.rs index e47737ac719..439418288c9 100644 --- a/libs/@local/graph/atlas/src/allocator.rs +++ b/libs/@local/graph/atlas/src/allocator.rs @@ -178,6 +178,19 @@ unsafe impl Allocator for MemoryUsageAllocator { } } +/// A value that can report the heap bytes it holds. +/// +/// Implementations report the bytes that would be released by dropping the value and leave out +/// the bytes the value occupies inline in its owner. Composite values sum their parts. A value that +/// holds nothing on the heap reports zero. +/// +/// An implementation using [`MemoryUsage`] reports its allocator's current tally rather than a +/// figure derived from the value's contents. +pub(crate) trait HeapMemoryUsage { + /// Returns the bytes this value holds on the heap. + fn heap_memory_usage(&self) -> u64; +} + #[cfg(test)] mod tests { mod miri { @@ -186,7 +199,7 @@ mod tests { use crate::allocator::MemoryUsageAllocator; #[test] - fn allocate_rises_the_counter_by_layout_size() { + fn allocation_tally() { let allocator = MemoryUsageAllocator::global(); let usage = allocator.memory_usage(); assert_eq!(usage.get(), 0); @@ -206,7 +219,7 @@ mod tests { } #[test] - fn allocate_zeroed_rises_and_reads_zero() { + fn zeroed_allocation_tally() { let allocator = MemoryUsageAllocator::global(); let usage = allocator.memory_usage(); @@ -230,7 +243,7 @@ mod tests { } #[test] - fn grow_tracks_the_size_delta() { + fn growth_tally() { let allocator = MemoryUsageAllocator::global(); let usage = allocator.memory_usage(); @@ -260,7 +273,7 @@ mod tests { } #[test] - fn grow_zeroed_tracks_the_delta_and_zeroes_the_tail() { + fn zeroed_growth_tally() { let allocator = MemoryUsageAllocator::global(); let usage = allocator.memory_usage(); @@ -302,7 +315,7 @@ mod tests { } #[test] - fn shrink_tracks_the_size_delta() { + fn shrink_tally() { let allocator = MemoryUsageAllocator::global(); let usage = allocator.memory_usage(); @@ -332,7 +345,7 @@ mod tests { } #[test] - fn vec_growth_through_the_allocator_tracks_pushes_and_returns_to_zero() { + fn vec_growth_tally() { let allocator = MemoryUsageAllocator::global(); let usage = allocator.memory_usage(); assert_eq!(usage.get(), 0); diff --git a/libs/@local/graph/atlas/src/bitset/compress.rs b/libs/@local/graph/atlas/src/bitset/compress.rs index 25103ad13de..30f73c33fd7 100644 --- a/libs/@local/graph/atlas/src/bitset/compress.rs +++ b/libs/@local/graph/atlas/src/bitset/compress.rs @@ -79,13 +79,7 @@ impl CompressedBitSet { self.rows.len() } - /// Returns whether the set admits no rows. - #[must_use] - pub(crate) fn is_empty(&self) -> bool { - self.rows.is_empty() - } - - /// Returns the set's retained container bytes. + /// Estimates the set's heap usage from its occupied containers. /// /// The figure sums the array, run and bitmap byte counts reported by /// [`RoaringBitmap::statistics`] plus [`Self::CONTAINER_ALLOWANCE`] per container. It is an @@ -100,21 +94,6 @@ impl CompressedBitSet { + statistics.n_bytes_bitset_containers + u64::from(statistics.n_containers) * Self::CONTAINER_ALLOWANCE } - - /// Returns whether the set admits every row of `[0, n)`. - /// - /// Rows at or above `n` never count against the answer: a set may admit them and still cover - /// the range below. `n = 0` asks for no rows and answers `true`, and a range wider than the - /// representable domain answers `false`. The check runs on the set's compressed runs rather - /// than its rows, so a covered million-row range costs what a handful of rows cost. - #[must_use] - pub(crate) fn contains_below(&self, n: u64) -> bool { - let Some(last) = n.checked_sub(1) else { - return true; - }; - - u32::try_from(last).is_ok_and(|last| self.rows.contains_range(0..=last)) - } } impl CompressedBitSet { @@ -146,11 +125,6 @@ impl CompressedBitSet { self.rows.insert(row) } - /// Removes `row`, returning whether the set changed. - pub(crate) fn remove(&mut self, row: T) -> bool { - u32::try_from(row.as_u64()).is_ok_and(|row| self.rows.remove(row)) - } - /// Returns whether the set admits `row`. /// /// A row above the representable domain is not admitted. diff --git a/libs/@local/graph/atlas/src/bitset/dense.rs b/libs/@local/graph/atlas/src/bitset/dense.rs index 5fc4718a6d2..591cbe2dfbf 100644 --- a/libs/@local/graph/atlas/src/bitset/dense.rs +++ b/libs/@local/graph/atlas/src/bitset/dense.rs @@ -1,7 +1,8 @@ #![expect(clippy::empty_enums, reason = "zerocopy uses them in the derive")] -use alloc::boxed::Box; +use alloc::{alloc::Allocator, boxed::Box}; use core::{ + clone::CloneToUninit, fmt, iter, marker::PhantomData, ops::{Index, IndexMut, Range}, @@ -43,7 +44,7 @@ const fn word_index_and_mask(row: u64) -> (usize, u64) { /// A byte frame [`DenseBitSlice::try_from_prefix`] refused. #[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum ParseDenseBitSliceError { +pub(crate) enum ParseDenseBitSliceError { /// The bytes end before the 8-byte domain header. Header { /// The refused buffer's byte length. @@ -238,7 +239,7 @@ impl DenseBitSlice { #[expect( clippy::integer_division, clippy::integer_division_remainder_used, - reason = "a frame is one header word plus whole storage words, so the division is exact" + reason = "a frame is one header word plus whole storage words. The division is exact" )] const unsafe fn from_frame_unchecked(bytes: &[u8]) -> &Self { let words = (bytes.len() - WORD_BYTES) / WORD_BYTES; @@ -257,7 +258,7 @@ impl DenseBitSlice { #[expect( clippy::integer_division, clippy::integer_division_remainder_used, - reason = "a frame is one header word plus whole storage words, so the division is exact" + reason = "a frame is one header word plus whole storage words. The division is exact" )] unsafe fn from_frame_unchecked_mut(bytes: &mut [u8]) -> &mut Self { let words = (bytes.len() - WORD_BYTES) / WORD_BYTES; @@ -350,14 +351,14 @@ impl DenseBitSlice { /// Returns the number of admitted rows below `row`: the row's rank in admission order. /// - /// A row at or beyond the domain ranks after every member, so it counts them all. The cost is - /// one popcount per word below `row`. - #[must_use] + /// A row at or beyond the domain counts all members. The cost is one popcount per word below + /// `row`. #[expect( clippy::integer_division, clippy::integer_division_remainder_used, reason = "the quotient names the row's word and the remainder its bit within that word" )] + #[must_use] pub(crate) fn count_below(&self, index: T) -> u64 { let row = index.as_u64().min(self.domain_size.get()); @@ -445,79 +446,48 @@ impl DenseBitSlice { }) }) } +} - /// Iterates the rows the set admits inside `range`, in ascending order. - /// - /// The range's end is clamped to the domain, so rows a longer range would name are simply - /// absent. - /// - /// # Panics - /// - /// This panics when `range.start` exceeds `range.end`. An inverted range admits no iteration - /// order, so it is a caller bug rather than an empty result. - pub(crate) fn iter_in(&self, range: Range) -> RowsIn<'_, T> { - let start = range.start.as_u64(); - let end = range.end.as_u64(); - assert!( - start <= end, - "an inverted row range admits no iteration order" - ); - - RowsIn { - words: &self.words, - position: start, - end: end.min(self.domain_size.get()), - marker: PhantomData, +// SAFETY: CloneToUninit requires a successful call to initialize a clone in the caller's +// destination. IntoBytes exposes the complete initialized representation without padding. Copying +// the domain header and all trailing words preserves the frame invariant under the source's +// word-count metadata. PhantomData occupies no bytes and does not clone a T. Therefore the byte +// clone initializes a valid DenseBitSlice. +unsafe impl CloneToUninit for DenseBitSlice { + unsafe fn clone_to_uninit(&self, dest: *mut u8) { + let bytes = self.as_bytes(); + + // SAFETY: The byte-slice clone requires writable destination storage for its full length + // with the CloneToUninit caller's access guarantees. IntoBytes makes bytes.len() equal + // size_of_val(self), and both views require alignment one. The same destination range and + // non-overlap obligation pass through unchanged. Therefore the byte-slice call initializes + // the full frame representation. + unsafe { + bytes.clone_to_uninit(dest); } } } -/// Iterator over the rows a [`DenseBitSlice`] admits inside a range, ascending. -/// -/// The cursor is `u64` so the word-boundary jump cannot overflow at the top of a `u32` row -/// domain. The end is at most the domain, so every word the cursor touches is in memory. -#[derive(Debug)] -pub(crate) struct RowsIn<'set, T> { - /// The set's member bits. - words: &'set [U64], - /// The next row to examine. - position: u64, - /// The first row past the range. - end: u64, - marker: PhantomData, -} - -impl Iterator for RowsIn<'_, T> { - type Item = T; - - #[expect( - clippy::integer_division, - clippy::integer_division_remainder_used, - reason = "the quotient names the cursor's word and the remainder its bit within that word" - )] - fn next(&mut self) -> Option { - while self.position < self.end { - // Every row below `end` lies in the domain, so the word index is in bounds. - #[expect(clippy::cast_possible_truncation)] - let word = self.words[(self.position / WORD_BITS as u64) as usize].get(); - // Mask off the bits below the cursor, then jump to the next set bit inside this - // word, if any. - let masked = word & (u64::MAX << (self.position % WORD_BITS as u64)); - let next = (self.position / WORD_BITS as u64) * WORD_BITS as u64 - + u64::from(masked.trailing_zeros()); - if masked != 0 { - if next >= self.end { - // The next set bit lies at or beyond the range. - break; - } - self.position = next + 1; - return Some(T::from_u64(next)); - } - // Skip to the next word boundary. - self.position = (self.position / WORD_BITS as u64 + 1) * WORD_BITS as u64; - } +impl Clone for Box, A> { + fn clone(&self) -> Self { + Self::clone_from_ref_in(&**self, Self::allocator(self).clone()) + } - None + /// Overwrites this frame's words with `source`'s in place, keeping the allocation. + /// + /// # Panics + /// + /// This panics when the two frames cover different domains, because a frame's word count is + /// fixed by its header and cannot follow `source`'s. + fn clone_from(&mut self, source: &Self) { + let &mut DenseBitSlice { + domain_size, + ref mut words, + marker: _, + } = &mut **self; + + assert_eq!(domain_size, source.domain_size); + words.clone_from_slice(&source.words); } } @@ -641,7 +611,7 @@ unsafe impl zerocopy::TryFromBytes for DenseBitSlice { /// A byte region [`DenseBitSliceArray::try_from_bytes`] refused. #[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum ParseDenseBitSliceArrayError { +pub(crate) enum ParseDenseBitSliceArrayError { /// The region's byte length is not what its domain header and frame count occupy. Length { /// The length the domain header and the frame count occupy. @@ -941,12 +911,17 @@ impl DenseBitSliceArray { } /// Returns the number of frames. - #[must_use] + /// + /// # Panics + /// + /// Panics if the frame stride exceeds `usize::MAX`, possible for a header-only array on a + /// 32-bit target. #[expect( clippy::integer_division, clippy::integer_division_remainder_used, - reason = "every door validated whole strides, so the division is exact" + reason = "the array construction contract requires a whole number of frame strides" )] + #[must_use] pub(crate) fn len(&self) -> usize { self.frames.len() / self.stride() } diff --git a/libs/@local/graph/atlas/src/bitset/mod.rs b/libs/@local/graph/atlas/src/bitset/mod.rs index c1eba3c7b9e..01ec79f8df9 100644 --- a/libs/@local/graph/atlas/src/bitset/mod.rs +++ b/libs/@local/graph/atlas/src/bitset/mod.rs @@ -18,7 +18,6 @@ pub(crate) use self::{ compress::CompressedBitSet, dense::{ DenseBitSlice, DenseBitSliceArray, ParseDenseBitSliceArrayError, ParseDenseBitSliceError, - RowsIn, }, }; diff --git a/libs/@local/graph/atlas/src/bitset/tests.rs b/libs/@local/graph/atlas/src/bitset/tests.rs index 4257f5210ca..6514924f371 100644 --- a/libs/@local/graph/atlas/src/bitset/tests.rs +++ b/libs/@local/graph/atlas/src/bitset/tests.rs @@ -14,7 +14,6 @@ use crate::identity::{EdgeRowId, NodeRowId}; fn starts_empty() { let set = CompressedBitSet::::new(); - assert!(set.is_empty()); assert_eq!(set.count(), 0); assert_eq!(set.iter().next(), None); assert!(!set.contains(NodeRowId::new(0))); @@ -56,68 +55,13 @@ fn from_rows_admits_every_row_and_iterates_in_order() { ); } -/// Range coverage demands exactly the rows below `n`. -/// -/// Rows above `n` never count against the answer, and `n = 0` holds vacuously. The fixture rows -/// straddle roaring's container boundary at 2^16, so a covered range crosses containers as well -/// as words, and no set covers a domain wider than the representable rows. -#[test] -fn contains_below_demands_every_row_of_the_range() { - let empty = CompressedBitSet::::new(); - assert!( - empty.contains_below(0), - "an empty range is covered vacuously" - ); - assert!(!empty.contains_below(1)); - - let hole = 0x1_0040_u64; - let mut set = - CompressedBitSet::from_rows((0..0x1_0100).filter(|&row| row != hole).map(NodeRowId::new)); - assert!( - set.contains_below(hole), - "the range below the hole is covered" - ); - assert!( - !set.contains_below(hole + 1), - "the hole breaks coverage at its own row" - ); - assert!( - !set.contains_below(0x1_0100), - "a row above the hole cannot repair the range below it" - ); - - set.insert(NodeRowId::new(hole)); - assert!( - set.contains_below(0x1_0100), - "filling the hole covers the range" - ); - assert!( - !set.contains_below(0x1_0101), - "coverage ends at the last admitted row" - ); - assert!( - !set.contains_below(u64::from(u32::MAX) + 2), - "a range wider than the representable domain is never covered" - ); -} - -#[test] -fn removal_reports_whether_the_set_changed() { - let mut set = CompressedBitSet::from_rows([1, 2].map(EdgeRowId::new)); - - assert!(set.remove(EdgeRowId::new(2))); - assert!(!set.remove(EdgeRowId::new(2))); - assert_eq!(set.iter().collect::>(), [EdgeRowId::new(1)]); -} - /// A row above the representable domain is not admitted, and the query answers rather than panics. #[test] fn rows_above_the_representable_domain_read_absent() { - let mut set = CompressedBitSet::from_rows([NodeRowId::new(1)]); + let set = CompressedBitSet::from_rows([NodeRowId::new(1)]); let beyond = NodeRowId::new(u64::from(u32::MAX) + 1); assert!(!set.contains(beyond)); - assert!(!set.remove(beyond)); assert_eq!(set.count(), 1); } @@ -407,36 +351,8 @@ fn dense_bit_slice_total_byte_len_counts_the_header_and_the_words() { ); } -#[test] -fn dense_bit_slice_iterates_ranges_across_word_boundaries() { - let mut set = DenseBitSlice::::new_empty(130); - for row in [0, 63, 64, 100, 129] { - set.insert(NodeRowId::new(row)); - } - - let rows_in = |start: u64, end: u64| { - set.iter_in(NodeRowId::new(start)..NodeRowId::new(end)) - .map(NodeRowId::as_u32) - .collect::>() - }; - - assert_eq!(rows_in(0, 130), [0, 63, 64, 100, 129]); - assert_eq!(rows_in(1, 129), [63, 64, 100]); - assert_eq!(rows_in(63, 65), [63, 64]); - assert_eq!(rows_in(64, 64), [] as [u32; 0]); - assert_eq!(rows_in(101, 130), [129]); - - // The end clamps to the domain, so a longer range names no extra rows. - assert_eq!(rows_in(101, 4_000), [129]); -} - -#[test] -#[should_panic(expected = "an inverted row range admits no iteration order")] -fn dense_bit_slice_range_iteration_rejects_inverted_ranges() { - let set = DenseBitSlice::::new_empty(100); - let _rows = set.iter_in(NodeRowId::new(60)..NodeRowId::new(2)); -} - +/// Union, intersection, and subtraction between two frames mutate the target and report change, +/// with a repeated union reporting none. #[test] fn dense_bit_slice_relations_apply_between_slices() { let mut target = DenseBitSlice::::new_empty(130); diff --git a/libs/@local/graph/atlas/src/cli/fit.rs b/libs/@local/graph/atlas/src/cli/fit.rs index ce346918445..4856e1d9332 100644 --- a/libs/@local/graph/atlas/src/cli/fit.rs +++ b/libs/@local/graph/atlas/src/cli/fit.rs @@ -1,6 +1,6 @@ //! The fit command that runs one production generation over the live store or a dump directory. -use core::{error::Error, fmt, num::NonZero, time::Duration}; +use core::{error::Error, fmt, num::NonZero, panic::UnwindSafe, time::Duration}; use std::{io, time::Instant}; use camino::{Utf8Path, Utf8PathBuf}; @@ -302,7 +302,10 @@ where self, client: &mut Client, credential: EmbedderArgs, - ) -> Result { + ) -> Result + where + P::Detached: UnwindSafe, + { // The math kernels reach this entry without passing through the shell's main. crate::math::kernel::verify_cpu_baseline(); @@ -356,10 +359,16 @@ where /// /// # Errors /// - /// Returns a [`FitError`] naming the step that failed: the run itself, or writing the - /// admission report. A refused dump arrives in the run's own chain, exactly as a refused - /// supply document does. - pub async fn run_offline(self, dump: &Utf8Path) -> Result { + /// Returns [`FitError`] if fitting the dump or writing the admission report fails. + /// + /// # Panics + /// + /// [`verify_cpu_baseline`](crate::math::kernel::verify_cpu_baseline) runs first, as in + /// [`Self::run`]. + pub async fn run_offline(self, dump: &Utf8Path) -> Result + where + P::Detached: UnwindSafe, + { // The math kernels reach this entry without passing through the shell's main. crate::math::kernel::verify_cpu_baseline(); diff --git a/libs/@local/graph/atlas/src/cli/shell.rs b/libs/@local/graph/atlas/src/cli/shell.rs index 49736e2192b..d2bc94047a6 100644 --- a/libs/@local/graph/atlas/src/cli/shell.rs +++ b/libs/@local/graph/atlas/src/cli/shell.rs @@ -1,5 +1,8 @@ //! The standalone binary's command line and entry point. +#[cfg(feature = "cli")] +use core::panic::UnwindSafe; + use camino::Utf8PathBuf; use clap::{Parser, Subcommand, ValueHint}; @@ -7,6 +10,8 @@ use clap::{Parser, Subcommand, ValueHint}; use super::EmbedderArgs; use super::{DumpArgs, FitArgs, PostgresArgs, ReportCommand, RootArgs}; use crate::integrity::SecretString; +#[cfg(feature = "cli")] +use crate::progress::Progress; /// The standalone atlas binary's command line. /// @@ -180,6 +185,31 @@ fn log_filter() -> tracing_subscriber::EnvFilter { .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")) } +/// Runs a prepared fit against the selected data source. +/// +/// # Errors +/// +/// Returns the connection or fit failure. +#[cfg(feature = "cli")] +async fn run_fit( + command: super::FitCommand + Sync>, + source: FitSource, +) -> Result { + match source { + FitSource::Live { store, credential } => { + let mut client = store.connect().await.map_err(DashboardError::Connect)?; + command + .run(&mut client, credential) + .await + .map_err(DashboardError::Fit) + } + FitSource::Offline(dump) => command + .run_offline(&dump) + .await + .map_err(DashboardError::Fit), + } +} + /// Runs one fit on the live dashboard, restoring the terminal before rendering anything. /// /// This installs the subscriber globally rather than around the run, because the pipeline reports @@ -214,20 +244,7 @@ async fn fit_on_dashboard( let outcome = async { let command = super::FitCommand::new(root, args).with_progress(observer); - match source { - FitSource::Live { store, credential } => { - let mut client = store.connect().await.map_err(DashboardError::Connect)?; - - command - .run(&mut client, credential) - .await - .map_err(DashboardError::Fit) - } - FitSource::Offline(dump) => command - .run_offline(&dump) - .await - .map_err(DashboardError::Fit), - } + run_fit(command, source).await } .await; @@ -239,6 +256,31 @@ async fn fit_on_dashboard( Ok(verdict) } +/// Runs a fit without a dashboard and renders its verdict or failure chain. +#[cfg(feature = "cli")] +async fn fit_logged(root: RootArgs, source: FitSource, args: FitArgs) -> std::process::ExitCode { + let command = super::FitCommand::new(root, args); + + let result = match source { + FitSource::Live { store, credential } => { + let mut client = match store.connect().await { + Ok(client) => client, + Err(error) => return render_failure(error), + }; + command.run(&mut client, credential).await + } + FitSource::Offline(dump) => command.run_offline(&dump).await, + }; + + match result { + Ok(verdict) => { + render_verdict(verdict); + std::process::ExitCode::SUCCESS + } + Err(error) => render_failure(error), + } +} + /// Runs the standalone atlas binary. /// /// Parses the command line and installs the log renderer before dispatching the command. The @@ -292,28 +334,7 @@ pub async fn main() -> std::process::ExitCode { openai_api_key, offline, tui: false, - } => { - let command = super::FitCommand::new(root, *args); - let result = match fit_source(store, openai_api_key, offline) { - FitSource::Live { store, credential } => { - let mut client = match store.connect().await { - Ok(client) => client, - Err(error) => return render_failure(error), - }; - - command.run(&mut client, credential).await - } - FitSource::Offline(dump) => command.run_offline(&dump).await, - }; - - match result { - Ok(verdict) => { - render_verdict(verdict); - std::process::ExitCode::SUCCESS - } - Err(error) => render_failure(error), - } - } + } => fit_logged(root, fit_source(store, openai_api_key, offline), *args).await, Command::Report { command } => match command.run().await { // The probe dumps its records as it solves and hands back no @@ -345,6 +366,8 @@ pub async fn main() -> std::process::ExitCode { #[cfg(all(test, feature = "cli"))] mod tests { + use core::assert_matches; + use camino::Utf8PathBuf; use clap::Parser as _; @@ -382,14 +405,14 @@ mod tests { "dump", ]) .expect("an offline fit needs neither the key nor the store flags"); - let _: Result<(), std::io::Error> = std::fs::remove_dir_all(&root); + std::fs::remove_dir_all(&root).expect("should remove the parsed generation root"); - assert!(matches!( + assert_matches!( cli.command, Command::Fit { offline: Some(_), .. } - )); + ); } } diff --git a/libs/@local/graph/atlas/src/cli/tui/render/rail.rs b/libs/@local/graph/atlas/src/cli/tui/render/rail.rs index 25f64a3bffa..15da715c316 100644 --- a/libs/@local/graph/atlas/src/cli/tui/render/rail.rs +++ b/libs/@local/graph/atlas/src/cli/tui/render/rail.rs @@ -285,7 +285,7 @@ fn embedding_counter(workload: EmbeddingWorkload) -> String { #[expect( clippy::integer_division, clippy::integer_division_remainder_used, - reason = "a cell lights once its whole share of the workload is covered, so the truncation is \ + reason = "a cell lights once its whole share of the workload is covered. The truncation is \ the reading" )] fn counter_bar(done: usize, total: NonZero) -> String { diff --git a/libs/@local/graph/atlas/src/cli/tui/render/tests.rs b/libs/@local/graph/atlas/src/cli/tui/render/tests.rs index e7b5a2b13f6..2c10655a303 100644 --- a/libs/@local/graph/atlas/src/cli/tui/render/tests.rs +++ b/libs/@local/graph/atlas/src/cli/tui/render/tests.rs @@ -8,11 +8,11 @@ use ratatui::{Terminal, backend::TestBackend, buffer::Buffer, layout::Rect, styl use super::{ ACCENT, frame, loss::{curve, step_bounds, value_bounds}, - map::{SKELETON, map_bounds}, + map::{SKELETON, map_bounds, render_map}, rail::duration, }; use crate::{ - cli::tui::state::{KnnActivity, RunState}, + cli::tui::state::{KnnActivity, PlacementMap, RunState}, math::{Vec2, d_non_negative, open_unit_fraction, unit_fraction}, progress::{Batch, DescentIteration, Stage}, salt::{ @@ -558,6 +558,42 @@ fn a_placement_with_no_extent_still_has_a_viewport() { assert!(vertical[0] < vertical[1], "{vertical:?}"); } +#[test] +fn map_bounds_range_edges() { + let inner = Rect::new(0, 0, 40, 20); + let inside = [Vec2::ZERO, Vec2::splat(f32::MAX / 2.0)]; + let [horizontal, vertical] = + map_bounds(&inside, inner).expect("should represent the margin below the range edge"); + for [low, high] in [horizontal, vertical] { + assert!(low.is_finite() && low < 0.0); + assert!(high.is_finite() && high > f64::from(f32::MAX / 2.0)); + } + assert_eq!( + map_bounds(&[Vec2::ZERO, Vec2::splat(f32::MAX)], inner), + None + ); +} + +#[test] +fn map_unrepresentable_viewport() { + // the origin would be drawn by an unrelated unit-viewport fallback. + let placement = PlacementMap { + positions: vec![Vec2::ZERO, Vec2::splat(f32::MAX)], + landmarks: 1, + }; + let mut terminal = Terminal::new(TestBackend::new(40, 12)).expect("should open a terminal"); + terminal + .draw(|target| render_map(target, target.area(), &placement)) + .expect("should draw the empty map frame"); + let buffer = terminal.backend().buffer(); + assert!(rows(buffer)[0].contains(" map ")); + for y in 1..11 { + for x in 1..39 { + assert_eq!(buffer[(x, y)].symbol(), " "); + } + } +} + /// A run whose stages have all landed, carrying `readings` of the admission battery. fn probed(readings: usize) -> RunState { let mut state = RunState::new(); diff --git a/libs/@local/graph/atlas/src/dataset/auxiliary.rs b/libs/@local/graph/atlas/src/dataset/auxiliary.rs index 50345686d75..a0db0682414 100644 --- a/libs/@local/graph/atlas/src/dataset/auxiliary.rs +++ b/libs/@local/graph/atlas/src/dataset/auxiliary.rs @@ -117,7 +117,7 @@ impl OwnedLegend { /// allocation fails. pub(crate) fn new(representative: OntologyRowId, label: &Label) -> Self { let mut boxed = Legend::new_box_zeroed_with_elems(label.len()) - .expect("a label's length fits the allocator's limits"); + .expect("the legend allocation should succeed"); boxed.representative_ontology = representative; // SAFETY: the write copies the bytes of a valid `&Label` whole. The field holds valid UTF-8 @@ -125,11 +125,6 @@ impl OwnedLegend { unsafe { boxed.label.0.as_bytes_mut() }.copy_from_slice(label.as_bytes()); Self(boxed) } - - /// Returns the legend's retained heap in bytes: the representative header and the label text. - pub(crate) fn heap_bytes(&self) -> u64 { - size_of_val(&*self.0) as u64 - } } impl Clone for OwnedLegend { diff --git a/libs/@local/graph/atlas/src/dataset/card/contents.rs b/libs/@local/graph/atlas/src/dataset/card/contents.rs index 47ec7e27fb3..310cbdfa5e6 100644 --- a/libs/@local/graph/atlas/src/dataset/card/contents.rs +++ b/libs/@local/graph/atlas/src/dataset/card/contents.rs @@ -61,7 +61,7 @@ impl fmt::Write for IndentationWriter { #[expect( clippy::string_slice, reason = "bytes below 0x80 are complete characters in UTF-8, never the interior of a \ - multi-byte sequence, so every 0x0A offset is a character boundary" + multi-byte sequence: every 0x0A offset is a character boundary" )] fn write_str(&mut self, s: &str) -> fmt::Result { let mut previous = 0; diff --git a/libs/@local/graph/atlas/src/dataset/card/hash/mod.rs b/libs/@local/graph/atlas/src/dataset/card/hash/mod.rs index f5d2305cc20..e1d56eee50c 100644 --- a/libs/@local/graph/atlas/src/dataset/card/hash/mod.rs +++ b/libs/@local/graph/atlas/src/dataset/card/hash/mod.rs @@ -483,8 +483,8 @@ fn recognizability(row: &ExampleRow<'_, A>) -> f64 { /// Returns `ln(1 + count)`. #[expect( clippy::cast_precision_loss, - reason = "the widening is the operation: counts above 2^53 round to the nearest representable \ - float, and the logarithm leaves that error far below the score's discrimination" + reason = "prominence uses an approximate logarithmic score, including rounded conversions of \ + counts above 2⁵³" )] fn ln_count(count: u64) -> f64 { (count as f64).ln_1p() diff --git a/libs/@local/graph/atlas/src/dataset/card/lint.rs b/libs/@local/graph/atlas/src/dataset/card/lint.rs index 6a699d78cf5..8d6a35f273e 100644 --- a/libs/@local/graph/atlas/src/dataset/card/lint.rs +++ b/libs/@local/graph/atlas/src/dataset/card/lint.rs @@ -13,7 +13,10 @@ pub(crate) enum IdentifierLeakError { /// The text embeds a UUID. Uuid, /// The text embeds a caller-supplied source identifier. - SourceIdentifier { identifier: String }, + SourceIdentifier { + /// The identifier found in the text. + identifier: String, + }, } impl fmt::Display for IdentifierLeakError { @@ -114,8 +117,8 @@ fn find_with_boundaries( /// Returns whether `identifier` occurs in `text` as a whole alphanumeric token. #[expect( clippy::string_slice, - reason = "offsets advance by whole characters from match starts, so slicing stays on \ - character boundaries" + reason = "offsets advance by whole characters from match starts, keeping slices on character \ + boundaries" )] fn contains_identifier(text: &str, identifier: &str) -> bool { let mut offset = 0; diff --git a/libs/@local/graph/atlas/src/dataset/card/select.rs b/libs/@local/graph/atlas/src/dataset/card/select.rs index 82cc7b12a36..fb9f3b9935b 100644 --- a/libs/@local/graph/atlas/src/dataset/card/select.rs +++ b/libs/@local/graph/atlas/src/dataset/card/select.rs @@ -21,9 +21,10 @@ use alloc::{ use core::num::NonZero; use std::collections::HashSet; +use crate::math::nz; + /// Per-group slot ceiling applied before cap relaxation. -pub(crate) const DEFAULT_GROUP_SLOT_CAP: NonZero = - NonZero::new(3).expect("the default slot cap is non-zero"); +pub(crate) const DEFAULT_GROUP_SLOT_CAP: NonZero = nz!(3); /// One adapter-owned candidate annotated for common selection. pub(crate) struct Candidate<'text, P, S, A: Allocator = Global> { diff --git a/libs/@local/graph/atlas/src/dataset/offline/dump/archive.rs b/libs/@local/graph/atlas/src/dataset/offline/dump/archive.rs index de646e7f493..86ce56f4bb2 100644 --- a/libs/@local/graph/atlas/src/dataset/offline/dump/archive.rs +++ b/libs/@local/graph/atlas/src/dataset/offline/dump/archive.rs @@ -267,7 +267,8 @@ impl Column { assert_eq!( serializer.pos(), tail, - "the column is the serializer's tail, so nothing else may write between pushes", + "the serializer position must equal the column tail. No other write may occur between \ + pushes", ); let resolver = value diff --git a/libs/@local/graph/atlas/src/dataset/offline/embedder.rs b/libs/@local/graph/atlas/src/dataset/offline/embedder.rs index 155e354b21b..4f623200b41 100644 --- a/libs/@local/graph/atlas/src/dataset/offline/embedder.rs +++ b/libs/@local/graph/atlas/src/dataset/offline/embedder.rs @@ -36,8 +36,8 @@ impl core::fmt::Display for MissingCardText { fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!( fmt, - "the dump holds no embedding for card-text hash {}, so the fit renders a text the \ - dump command never embedded (differing annotation flags are the usual cause)", + "the dump holds no embedding for card-text hash {}. Differing annotation flags can \ + cause this mismatch: use a dump whose flags match the fit's", self.hash, ) } diff --git a/libs/@local/graph/atlas/src/dataset/offline/tests.rs b/libs/@local/graph/atlas/src/dataset/offline/tests.rs index d70e0b1b4e9..6c3d21f2b54 100644 --- a/libs/@local/graph/atlas/src/dataset/offline/tests.rs +++ b/libs/@local/graph/atlas/src/dataset/offline/tests.rs @@ -1,5 +1,5 @@ use alloc::borrow::Cow; -use core::num::NonZero; +use core::assert_matches; use std::{collections::HashMap, fs, io}; use camino::{Utf8Path, Utf8PathBuf}; @@ -23,7 +23,7 @@ use super::{ use crate::{ identity::{NodeRowId, OntologyRowId}, integrity::{Sha256, Sha256Digest, Update as _}, - math::{AlignedVecN, BoxedVecN, unit_fraction}, + math::{AlignedVecN, BoxedVecN, nz, unit_fraction}, postgres::id::{ArchivedEntityId, ArchivedOntologyTypeUuid}, progress::NoProgress, salt::{ @@ -32,14 +32,14 @@ use crate::{ }, }; -/// A nonzero literal, checked at compile time. -macro_rules! nz { - ($value:expr) => { - const { NonZero::new($value).expect("the literal is nonzero") } - }; -} - -/// A fresh per-test dump directory under the system temp directory. +/// Returns the dump path for `name` after attempting to remove its directory. +/// +/// Relative names resolve against a process-specific path under the system temporary directory. A +/// failed removal can leave existing contents in place. This helper creates no directory. +/// +/// # Panics +/// +/// Panics if the system temporary directory's path is not UTF-8. fn scratch(name: &str) -> Utf8PathBuf { let directory = Utf8PathBuf::from_path_buf(std::env::temp_dir()) .expect("the system temp path is UTF-8") @@ -79,7 +79,7 @@ fn vector(seed: u8) -> BoxedVecN { /// Asserts that a served embedding borrows its bytes from inside one stream file's mapping. #[expect( clippy::ptr_arg, - reason = "the assertion discriminates the Cow's arms, so the Cow itself is the subject" + reason = "the assertion discriminates the Cow's arms: the Cow itself is the subject" )] #[track_caller] fn assert_borrowed_from(map: &[u8], embedding: &Cow<'_, AlignedVecN>) { @@ -750,15 +750,13 @@ async fn open_refuses_a_tampered_stream() { let error = OfflineDataset::open(&directory).expect_err("a tampered stream must refuse to open"); - assert!( - matches!( - error, - OpenDumpError::Digest { - kind: StreamKind::Nodes, - .. - }, - ), - "the refusal names the tampered stream: {error}", + assert_matches!( + error, + OpenDumpError::Digest { + kind: StreamKind::Nodes, + .. + }, + "the refusal names the tampered stream: {error}" ); } @@ -790,15 +788,13 @@ async fn open_refuses_a_defective_archive() { let error = OfflineDataset::open(&directory).expect_err("a defective archive must refuse to open"); - assert!( - matches!( - error, - OpenDumpError::Archive { - kind: StreamKind::Nodes, - .. - }, - ), - "the refusal names the defective stream: {error}", + assert_matches!( + error, + OpenDumpError::Archive { + kind: StreamKind::Nodes, + .. + }, + "the refusal names the defective stream: {error}" ); } @@ -836,17 +832,15 @@ async fn open_refuses_an_embedding_position_outside_the_column() { let error = OfflineDataset::open(&directory) .expect_err("an out-of-column embedding position must refuse to open"); - assert!( - matches!( - error, - OpenDumpError::EmbeddingPosition { - kind: StreamKind::Edges, - record: 0, - position: 3, - embeddings: 1, - }, - ), - "the refusal names the defective record: {error}", + assert_matches!( + error, + OpenDumpError::EmbeddingPosition { + kind: StreamKind::Edges, + record: 0, + position: 3, + embeddings: 1, + }, + "the refusal names the defective record: {error}" ); } diff --git a/libs/@local/graph/atlas/src/file/array/mod.rs b/libs/@local/graph/atlas/src/file/array/mod.rs index 2cf4c63c1c1..40fcd315149 100644 --- a/libs/@local/graph/atlas/src/file/array/mod.rs +++ b/libs/@local/graph/atlas/src/file/array/mod.rs @@ -57,7 +57,7 @@ #![expect( clippy::little_endian_bytes, reason = "the fields are little endian, while the magic discriminant stores native endian, so \ - a cross-endian reader fails loudly at the magic instead of misreading fields" + a cross-endian reader fails magic validation instead of misreading fields" )] use core::fmt; @@ -70,7 +70,7 @@ mod tests; mod write; pub(crate) use self::{ - read::{ArrayFile, OpenArrayError}, + read::{ArrayFile, InvalidColumnError, OpenArrayError}, write::{ArrayWriter, ColumnScalar, SizedArrayWriter, SizedColumn}, }; use super::region::machine::{Architecture, Machine}; @@ -146,9 +146,9 @@ pub(crate) enum Version { /// The element type of an array file. #[expect( dead_code, - reason = "currently unused variants constitute valid variantions and may be used in the \ - immediate future, omitting them now means that the variant indices would be out of \ - order and would require breaking changes." + reason = "the wire format declares the little-endian half of the element matrix. A reader \ + accepts every tag from a header, but this crate constructs only the widths its \ + writers emit" )] #[derive( Debug, diff --git a/libs/@local/graph/atlas/src/file/array/read.rs b/libs/@local/graph/atlas/src/file/array/read.rs index e962d022d57..3f67578d601 100644 --- a/libs/@local/graph/atlas/src/file/array/read.rs +++ b/libs/@local/graph/atlas/src/file/array/read.rs @@ -6,11 +6,14 @@ use std::path::Path; use hashql_core::id::{Id, IdSlice}; use zerocopy::{FromBytes as _, LE, U64}; -use super::{Architecture, ArrayVariant, FileHeader, write::ColumnScalar}; +use super::{Architecture, ArrayShape, ArrayVariant, Dim, FileHeader, write::ColumnScalar}; use crate::{ - file::region::{ - PAGE_BYTES, - header::{HeaderError, HeaderMap}, + file::{ + ArtifactFile, + region::{ + PAGE_BYTES, + header::{HeaderError, HeaderMap}, + }, }, integrity::Sha256Digest, math::{AlignedVecN, Vec2}, @@ -18,7 +21,7 @@ use crate::{ /// Opening an array file failed. #[derive(Debug)] -pub enum OpenArrayError { +pub(crate) enum OpenArrayError { /// Reading the header page failed. Header(HeaderError), /// The file length contradicts the header's shape. @@ -72,6 +75,61 @@ impl Error for OpenArrayError { } } +/// An array file's element stamp is not the requested column's. +#[derive(Debug, Copy, Clone)] +pub(crate) enum InvalidColumnError { + /// The file records another element variant. + Variant { + /// The variant the header records. + recorded: ArrayVariant, + /// The element type's variant. + expected: ArrayVariant, + }, + /// The file's row shape is not the element type's trailing shape. + Shape { + /// The shape the header records, the row count first. + recorded: ArrayShape, + /// The dimensions the element type adds beyond the row count. + expected: &'static [Dim], + }, +} + +/// Writes `dims` as a comma-separated list. +fn write_dims(fmt: &mut fmt::Formatter<'_>, dims: &[Dim]) -> fmt::Result { + for (index, dim) in dims.iter().enumerate() { + if index > 0 { + fmt.write_str(", ")?; + } + write!(fmt, "{}", dim.get())?; + } + + Ok(()) +} + +impl fmt::Display for InvalidColumnError { + #[expect( + clippy::use_debug, + reason = "the variant names are the format's own element vocabulary" + )] + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Variant { recorded, expected } => write!( + fmt, + "the file records {recorded:?} elements where the column reads {expected:?}", + ), + Self::Shape { recorded, expected } => { + fmt.write_str("the file's rows have shape [")?; + write_dims(fmt, recorded.dims().get(1..).unwrap_or(&[]))?; + fmt.write_str("] where the column's rows have shape [")?; + write_dims(fmt, expected)?; + fmt.write_str("]") + } + } + } +} + +impl Error for InvalidColumnError {} + /// An array file mapped read-only into memory. /// /// Opening parses the header and checks the format's single structural rule. An open file always @@ -82,7 +140,9 @@ pub(crate) struct ArrayFile { map: HeaderMap, } -impl ArrayFile { +impl ArtifactFile for ArrayFile { + type Error = OpenArrayError; + /// Opens and maps the array file at `path`. /// /// # Errors @@ -91,7 +151,8 @@ impl ArrayFile { /// [`OpenArrayError::Length`] when the file length contradicts the header's shape, and /// [`OpenArrayError::ForeignArchitecture`] when the other byte order wrote the file's native /// elements. - pub(crate) fn open(path: impl AsRef) -> Result { + #[tracing::instrument(skip_all)] + fn open(path: impl AsRef) -> Result { let map = HeaderMap::::open(path).map_err(OpenArrayError::Header)?; let header = map.header(); @@ -109,7 +170,9 @@ impl ArrayFile { Ok(Self { map }) } +} +impl ArrayFile { /// Borrows the parsed header at the head of the mapping. #[inline] #[must_use] @@ -136,24 +199,68 @@ impl ArrayFile { /// [`InvalidColumnError::Shape`] when its row shape is not `T`'s trailing shape. /// /// [`SizedColumn`]: super::SizedColumn - #[must_use] - pub(crate) fn column(&self) -> Option<&IdSlice> + pub(crate) fn column(&self) -> Result<&IdSlice, InvalidColumnError> where I: Id, T: ColumnScalar + zerocopy::FromBytes + zerocopy::KnownLayout, { - if self.header().variant() != T::VARIANT { - return None; + let header = self.header(); + if header.variant() != T::VARIANT { + return Err(InvalidColumnError::Variant { + recorded: header.variant(), + expected: T::VARIANT, + }); } - match self.header().shape.dims() { + match header.shape.dims() { [] => {} [_, trailing @ ..] if trailing == T::TRAILING => {} - _ => return None, + _ => { + return Err(InvalidColumnError::Shape { + recorded: header.shape, + expected: T::TRAILING, + }); + } } - let elements = <[T]>::ref_from_bytes(self.data()).ok()?; - Some(IdSlice::from_raw(elements)) + let elements = <[T]>::ref_from_bytes(self.data()) + .expect("the stamp fixes the element size and the open validated the length"); + Ok(IdSlice::from_raw(elements)) + } + + /// Views the data as one typed column without re-reading the element type metadata. + /// + /// The header variant identifies the stored scalar type. For a non-empty file, its trailing + /// shape determines how those scalars form each `T`. [`ArrayFile::column`] also accepts the + /// empty shape as a zero-row column. A holder can validate that interpretation once before + /// using this unchecked view. + /// + /// # Safety + /// + /// A prior `self.column::()` must have succeeded on this same file. That call checks the + /// element variant and trailing shape, with the documented empty-shape exception, and + /// constructs a typed view over the file's data. The file publication contract keeps the + /// mapped bytes immutable after opening, preserving that interpretation. + pub(crate) unsafe fn column_unchecked(&self) -> &IdSlice + where + I: Id, + T: ColumnScalar + zerocopy::FromBytes + zerocopy::KnownLayout, + { + let data = self.data(); + // The required prior typed view establishes that the byte length contains a whole number + // of `T` values. Therefore this division is exact. + let length = data.len().div_euclid(size_of::()); + + // SAFETY: The required prior `column::` call constructed `[T]` from this exact data + // range. Its success establishes alignment for `T` and validity of the actual range, + // while `T: FromBytes` establishes that every bit pattern is valid for `T`. The existing + // `[u8]` slice establishes initialization, and its length is exactly the byte length of + // `length` values of `T`. The byte slice and constructed typed slice borrow the same range + // through shared references from the mapping owned by `self`. That mapping keeps the + // allocation live. The file publication contract keeps it immutable. This slice introduces + // no mutable alias. Constructing the shared slice is therefore sound. + let slice = unsafe { core::slice::from_raw_parts(data.as_ptr().cast::(), length) }; + IdSlice::from_raw(slice) } /// Views the data as `N`-component SIMD-aligned vectors. diff --git a/libs/@local/graph/atlas/src/file/array/tests.rs b/libs/@local/graph/atlas/src/file/array/tests.rs index 8bd53f37197..9e6a92d1016 100644 --- a/libs/@local/graph/atlas/src/file/array/tests.rs +++ b/libs/@local/graph/atlas/src/file/array/tests.rs @@ -12,11 +12,11 @@ use zerocopy::{FromBytes as _, IntoBytes as _, TryFromBytes as _}; use super::{ ArrayShape, ArrayVariant, ArrayWriter, Dim, FileHeader, PaddedFileHeader, SizedArrayWriter, SizedColumn, - read::{ArrayFile, OpenArrayError}, + read::{ArrayFile, InvalidColumnError, OpenArrayError}, }; use crate::{ file::{ - WriteInto as _, + ArtifactFile as _, WriteInto as _, region::{PAGE_BYTES, header::HeaderError, machine::Machine}, }, identity::{BasePosition, EdgeRowId, NodeRowId}, @@ -457,9 +457,22 @@ fn column_round_trips_the_element_stamp() { assert_eq!(pairs.as_raw(), &rows); // Same variant, different trailing shape: the flat element refuses the pair file. - assert!(opened.column::().is_none()); + let InvalidColumnError::Shape { recorded, expected } = opened + .column::() + .expect_err("a flat element cannot view a pair file") + else { + panic!("a pair file under a flat element is a shape mismatch"); + }; + assert_eq!(recorded.dims(), &[Dim::new(2), Dim::new(2)]); + assert_eq!(expected, &[] as &[Dim]); // A different variant refuses outright. - assert!(opened.column::().is_none()); + assert_matches!( + opened.column::(), + Err(InvalidColumnError::Variant { + recorded: ArrayVariant::U64Le, + expected: ArrayVariant::U32Le, + }) + ); } /// Zero promised rows seal as the zero-element array immediately. diff --git a/libs/@local/graph/atlas/src/file/attraction/mod.rs b/libs/@local/graph/atlas/src/file/attraction/mod.rs index ec93ecbb78c..2636e58c2d1 100644 --- a/libs/@local/graph/atlas/src/file/attraction/mod.rs +++ b/libs/@local/graph/atlas/src/file/attraction/mod.rs @@ -49,8 +49,8 @@ #![expect( clippy::little_endian_bytes, reason = "the id and count fields are little endian, while the magic discriminant stores \ - native endian, so a cross-endian reader fails loudly at the magic instead of \ - misreading fields" + native endian. A cross-endian reader fails magic validation instead of misreading \ + fields" )] use core::fmt; diff --git a/libs/@local/graph/atlas/src/file/classifier/mod.rs b/libs/@local/graph/atlas/src/file/classifier/mod.rs index 9d6f1d372fa..e55056ee0b7 100644 --- a/libs/@local/graph/atlas/src/file/classifier/mod.rs +++ b/libs/@local/graph/atlas/src/file/classifier/mod.rs @@ -51,7 +51,7 @@ #![expect( clippy::little_endian_bytes, reason = "the fields are little endian, while the magic discriminant stores native endian, so \ - a cross-endian reader fails loudly at the magic instead of misreading fields" + a cross-endian reader fails magic validation instead of misreading fields" )] use core::fmt; diff --git a/libs/@local/graph/atlas/src/file/generation/error.rs b/libs/@local/graph/atlas/src/file/generation/error.rs new file mode 100644 index 00000000000..3ccf772dcaf --- /dev/null +++ b/libs/@local/graph/atlas/src/file/generation/error.rs @@ -0,0 +1,223 @@ +//! Error types for [`GenerationRoot`](super::GenerationRoot) and [`Generation`](super::Generation). + +use core::{error::Error, fmt}; +use std::{ffi::OsString, io}; + +use super::GenerationId; +use crate::{ + file::repository::FileName, + integrity::{ParseHexError, Sha256Digest}, +}; + +/// A staging could not seal into a published generation. +#[derive(Debug)] +pub(crate) enum SealError { + /// A manifest-listed file is absent from the staging directory. + Missing { + /// The absent file's name. + name: FileName, + }, + /// A staged file is not listed in the manifest. + Unlisted { + /// The unlisted file's name. + name: OsString, + }, + /// A generation with this metadata document is already published. + AlreadyPublished(GenerationId), + /// The metadata document failed to serialize. + Document(serde_json::Error), + /// A write, sync, or rename failed. + Io(io::Error), +} + +impl fmt::Display for SealError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing { name } => { + write!(fmt, "the manifest-listed file {name} is not staged") + } + Self::Unlisted { name } => write!( + fmt, + "the staged file {} is not listed in the manifest", + name.display(), + ), + Self::AlreadyPublished(id) => { + write!(fmt, "generation {id} is already published") + } + Self::Document(error) => { + write!(fmt, "the metadata document failed to serialize: {error}") + } + Self::Io(error) => write!(fmt, "the generation failed to persist: {error}"), + } + } +} + +impl Error for SealError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Document(error) => Some(error), + Self::Io(error) => Some(error), + Self::Missing { .. } | Self::Unlisted { .. } | Self::AlreadyPublished(_) => None, + } + } +} + +/// Reading the current-generation pointer failed. +#[derive(Debug)] +pub(crate) enum CurrentError { + /// The pointer's content is not a generation id. + Corrupt(ParseHexError), + /// Reading the pointer failed. + Io(io::Error), +} + +impl fmt::Display for CurrentError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Corrupt(error) => write!( + fmt, + "the current-generation pointer does not name a generation: {error}", + ), + Self::Io(error) => write!( + fmt, + "the current-generation pointer failed to read: {error}", + ), + } + } +} + +impl Error for CurrentError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Corrupt(error) => Some(error), + Self::Io(error) => Some(error), + } + } +} + +/// Activating a generation failed. +#[derive(Debug)] +pub(crate) enum ActivateError { + /// The generation is not published in this root. + Unpublished(GenerationId), + /// Locking the root or replacing the pointer failed. + Io(io::Error), +} + +impl fmt::Display for ActivateError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unpublished(id) => { + write!(fmt, "generation {id} is not published in this root") + } + Self::Io(error) => write!(fmt, "generation activation failed: {error}"), + } + } +} + +impl Error for ActivateError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Unpublished(_) => None, + Self::Io(error) => Some(error), + } + } +} + +impl From for ActivateError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +/// Opening a published generation failed. +#[derive(Debug)] +pub(crate) enum OpenError { + /// The generation is not published in this root. + Unpublished(GenerationId), + /// The document's bytes do not hash to the generation id. + Identity { + /// The generation the caller asked for. + id: GenerationId, + /// What the document's bytes actually hash to. + actual: Sha256Digest, + }, + /// The document does not parse as a repository this module speaks. + Document(serde_json::Error), + /// Reading the document failed. + Io(io::Error), +} + +impl fmt::Display for OpenError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unpublished(id) => { + write!(fmt, "generation {id} is not published in this root") + } + Self::Identity { id, actual } => write!( + fmt, + "the metadata document of generation {id} hashes to {actual}", + ), + Self::Document(error) => { + write!(fmt, "the metadata document failed to deserialize: {error}") + } + Self::Io(error) => write!(fmt, "the metadata document failed to read: {error}"), + } + } +} + +impl Error for OpenError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Document(error) => Some(error), + Self::Io(error) => Some(error), + Self::Unpublished(_) | Self::Identity { .. } => None, + } + } +} + +/// A failure removing an inactive generation. +#[derive(Debug)] +pub(crate) enum RemoveError { + /// Reading or parsing the current-generation pointer failed. + Current(CurrentError), + /// The current-generation pointer still names this generation. + Active(GenerationId), + /// Locking the root, removing the directory or syncing the root failed. + Io(io::Error), +} + +impl fmt::Display for RemoveError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Current(error) => write!( + fmt, + "could not read the current-generation pointer: {error}" + ), + Self::Active(id) => write!(fmt, "generation {id} is active"), + Self::Io(error) => write!(fmt, "could not persist generation removal: {error}"), + } + } +} + +impl Error for RemoveError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Current(error) => Some(error), + Self::Active(_) => None, + } + } +} + +impl From for RemoveError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for RemoveError { + fn from(error: CurrentError) -> Self { + Self::Current(error) + } +} diff --git a/libs/@local/graph/atlas/src/file/generation/mod.rs b/libs/@local/graph/atlas/src/file/generation/mod.rs index fa55429139a..7b5c75ed59c 100644 --- a/libs/@local/graph/atlas/src/file/generation/mod.rs +++ b/libs/@local/graph/atlas/src/file/generation/mod.rs @@ -4,35 +4,36 @@ //! document. [`GenerationRoot::current`] resolves the active generation, and //! [`GenerationRoot::open`] verifies its metadata against that identity. //! -//! Every visible entry of the root is a complete generation or the pointer. Dot prefixes mark -//! staging directories and the pointer's replacement file, and the rename into place is atomic, so -//! a failed or interrupted publish leaves only dot-prefixed transients behind, never a partial -//! generation. Sealing syncs the staged files before the rename and the root directory after it, so -//! a generation that is visible is also durable. - -use alloc::collections::BTreeSet; -use core::{error::Error, fmt, str::FromStr}; +//! [`StagedGeneration::seal`] atomically publishes a complete staging directory and syncs the root +//! before returning. Staging directories and pointer replacement files use dot prefixes. +//! [`GenerationRoot::activate`] and [`GenerationRoot::remove`] serialize through a persistent root +//! lock. +#![expect(clippy::empty_enums, reason = "zerocopy uses them in the derive")] + +use core::{fmt, str::FromStr}; use std::{ - ffi::OsString, fs::{self, File}, - io::{self, BufWriter, Write as _}, + io::{self, Write as _}, }; use camino::{Utf8Path, Utf8PathBuf}; use uuid::Uuid; -use super::{ - WriteAs, - repository::{Artifact, Binding, FileName}, - salt::SaltRepository, -}; -use crate::integrity::{ParseHexError, Sha256, Sha256Digest, Update as _}; +use crate::integrity::{ParseHexError, Sha256Digest}; +mod error; mod open; +mod scratch; +mod staging; #[cfg(test)] mod tests; -pub(crate) use self::open::{Generation, OpenError}; +pub(crate) use self::{ + error::{ActivateError, CurrentError, OpenError, RemoveError, SealError}, + open::Generation, + scratch::ScratchDirectory, + staging::{PublishedGeneration, StagedGeneration}, +}; /// The metadata document's file name within a generation directory. pub(crate) const METADATA_FILE: &str = "metadata.json"; @@ -40,123 +41,8 @@ pub(crate) const METADATA_FILE: &str = "metadata.json"; /// The current-generation pointer's file name within the root. const CURRENT_FILE: &str = "current"; -/// A staging could not seal into a published generation. -#[derive(Debug)] -pub(crate) enum SealError { - /// A manifest-listed file is absent from the staging directory. - Missing { - /// The absent file's name. - name: FileName, - }, - /// A staged file is not listed in the manifest. - Unlisted { - /// The unlisted file's name. - name: OsString, - }, - /// A generation with this metadata document is already published. - AlreadyPublished(GenerationId), - /// The metadata document failed to serialize. - Document(serde_json::Error), - /// A write, sync, or rename failed. - Io(io::Error), -} - -impl fmt::Display for SealError { - fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Missing { name } => { - write!(fmt, "the manifest-listed file {name} is not staged") - } - Self::Unlisted { name } => write!( - fmt, - "the staged file {} is not listed in the manifest", - name.display(), - ), - Self::AlreadyPublished(id) => { - write!(fmt, "generation {id} is already published") - } - Self::Document(error) => { - write!(fmt, "the metadata document failed to serialize: {error}") - } - Self::Io(error) => write!(fmt, "the generation failed to persist: {error}"), - } - } -} - -impl Error for SealError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Document(error) => Some(error), - Self::Io(error) => Some(error), - Self::Missing { .. } | Self::Unlisted { .. } | Self::AlreadyPublished(_) => None, - } - } -} - -/// Reading the current-generation pointer failed. -#[derive(Debug)] -pub enum CurrentError { - /// The pointer's content is not a generation id. - Corrupt(ParseHexError), - /// Reading the pointer failed. - Io(io::Error), -} - -impl fmt::Display for CurrentError { - fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Corrupt(error) => write!( - fmt, - "the current-generation pointer does not name a generation: {error}", - ), - Self::Io(error) => write!( - fmt, - "the current-generation pointer failed to read: {error}", - ), - } - } -} - -impl Error for CurrentError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Corrupt(error) => Some(error), - Self::Io(error) => Some(error), - } - } -} - -/// Activating a generation failed. -#[derive(Debug)] -pub(crate) enum ActivateError { - /// The generation is not published in this root. - Unpublished(GenerationId), - /// Replacing the pointer failed. - Io(io::Error), -} - -impl fmt::Display for ActivateError { - fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Unpublished(id) => { - write!(fmt, "generation {id} is not published in this root") - } - Self::Io(error) => write!( - fmt, - "the current-generation pointer failed to replace: {error}", - ), - } - } -} - -impl Error for ActivateError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Unpublished(_) => None, - Self::Io(error) => Some(error), - } - } -} +/// The persistent root lock's file name. +const LOCK_FILE: &str = ".generation.lock"; /// The identity of one published generation, the SHA-256 of its metadata document. /// @@ -173,12 +59,25 @@ impl Error for ActivateError { serde::Serialize, serde::Deserialize, schemars::JsonSchema, + zerocopy::FromBytes, + zerocopy::IntoBytes, + zerocopy::Immutable, + zerocopy::Unaligned, + zerocopy::KnownLayout, )] #[serde(transparent)] #[schemars(transparent)] -pub struct GenerationId(Sha256Digest); +#[repr(transparent)] +pub(crate) struct GenerationId(Sha256Digest); impl GenerationId { + /// Adopts a digest without reading a metadata document. + #[inline] + #[cfg(test)] // serve's wire tests construct synthetic identities. + pub(crate) const fn from_digest(digest: Sha256Digest) -> Self { + Self(digest) + } + /// Returns the digest of the generation's metadata document. #[inline] #[must_use] @@ -258,10 +157,7 @@ impl GenerationRoot { let path = self.path.join(format!(".stage-{}", Uuid::now_v7())); fs::create_dir_all(&path)?; - Ok(StagedGeneration { - root: self.path.clone(), - path, - }) + Ok(StagedGeneration::new(self.path.clone(), path)) } /// Returns the active generation, or [`None`] before the first activation. @@ -293,9 +189,11 @@ impl GenerationRoot { /// /// # Errors /// - /// Returns an error when this root has not published the generation or when replacing the - /// pointer fails. + /// Returns an error when locking the root fails, this root has not published the generation, or + /// replacing the pointer fails. + #[tracing::instrument(skip_all, err, fields(generation = %id))] pub(crate) fn activate(&self, id: GenerationId) -> Result<(), ActivateError> { + let _lock = self.lock()?; if !self.generation_path(id).is_dir() { return Err(ActivateError::Unpublished(id)); } @@ -304,10 +202,16 @@ impl GenerationRoot { let result = self.replace_pointer(&temporary, id); if result.is_err() { - drop(fs::remove_file(&temporary)); + let result = fs::remove_file(&temporary); + if let Err(error) = result + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!(path = %temporary, error = %error, "failed to remove temporary current pointer"); + } } - result.map_err(ActivateError::Io) + result?; + Ok(()) } /// Atomically replaces the current pointer and syncs it to disk. @@ -331,255 +235,58 @@ impl GenerationRoot { Ok(()) } -} - -/// A dot-prefixed directory for one run's transient working state. -/// -/// Nothing here is an artifact. The run that creates the contents also consumes them, and dropping -/// the handle removes the whole directory. -#[derive(Debug)] -#[clippy::has_significant_drop] -pub(crate) struct ScratchDirectory { - path: Utf8PathBuf, -} -impl ScratchDirectory { - /// Adopts an existing directory as a scratch root. + /// Takes the root's exclusive lock, blocking until it is free. /// - /// Dropping the value removes the directory and everything inside. - pub(crate) const fn new(path: Utf8PathBuf) -> Self { - Self { path } - } - - /// Creates (or reuses) a named subdirectory and returns its path. + /// Activation and removal serialize through it. A removal cannot delete the generation an + /// activation is about to name. Readers hold no lock: [`current`](Self::current) reads the + /// pointer file and sees whichever of the two pointer versions the rename has published. The + /// lock releases when the returned file drops. /// /// # Errors /// - /// Returns an error when creating the subdirectory fails. - pub(crate) fn directory(&self, name: &str) -> io::Result { - let path = self.path.join(name); - fs::create_dir_all(&path)?; - - Ok(path) + /// Returns the [`io::Error`] of opening or locking the lock file. + fn lock(&self) -> io::Result { + // The lock file must retain its inode across acquisitions. Removing it would let concurrent + // opens lock different files for the same root. + let file = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(self.path().join(LOCK_FILE))?; + file.lock()?; + Ok(file) } /// Removes an inactive generation while excluding concurrent activation. /// - /// # Errors - /// - /// Returns an error when creating the scratch root or the file fails. - pub(crate) fn file(&self, name: &str) -> io::Result<(Utf8PathBuf, File)> { - let path = self.path.join(name); - fs::create_dir_all(&self.path)?; - - File::create(&path).map(|file| (path, file)) - } -} - -impl Drop for ScratchDirectory { - fn drop(&mut self) { - drop(fs::remove_dir_all(&self.path)); - } -} - -/// Drops the write permission on a file about to publish. -/// -/// A published file is immutable, so rewriting one becomes an OS error, while removal keeps -/// working through the containing directory's permissions. -fn make_readonly(file: &File) -> io::Result<()> { - let mut permissions = file.metadata()?.permissions(); - permissions.set_readonly(true); - file.set_permissions(permissions) -} - -/// A generation under assembly in a staging directory. -/// -/// Stages write their artifacts directly into the staging directory through -/// [`create`](Self::create) and map them back through [`path_of`](Self::path_of), so sealing -/// renames files already in place and never copies. Dropping an unsealed staging removes it. -#[derive(Debug)] -#[clippy::has_significant_drop] -pub(crate) struct StagedGeneration { - root: Utf8PathBuf, - path: Utf8PathBuf, -} - -impl StagedGeneration { - /// Creates (or truncates) a staged file for writing. - /// - /// # Errors - /// - /// Returns an error when creating the file fails. - pub(crate) fn create(&self, name: &FileName) -> io::Result { - File::create(self.path.join(name.as_str())) - } - - /// Returns the path of a staged file. - #[must_use] - pub(crate) fn path_of(&self, name: &FileName) -> Utf8PathBuf { - self.path.join(name.as_str()) - } - - /// Stages one value as the artifact it is admitted to write. - /// - /// The value writes itself into the artifact's pinned staged file through one buffered pass, - /// and the written bytes' digest binds to the artifact as the typed entry the seal - /// publishes. The values a given artifact accepts are its [`WriteAs`] impls. + /// Blocks until the root lock is available and holds it through removal and root + /// synchronization. Open artifact descriptors remain valid after removal. /// /// # Errors /// - /// Returns an error when creating or flushing the staged file fails, and the value's own - /// error when its write fails. - #[expect( - unused_variables, - clippy::needless_pass_by_value, - reason = "used to signal the artifact's pinned name" - )] - pub(crate) fn stage(&self, artifact: A, value: V) -> Result, V::Error> - where - A: Artifact, - V: WriteAs>, - { - let mut writer = BufWriter::new(self.create(&A::NAME)?); - let hash = value.write_into(&mut writer)?; - writer.flush()?; - - Ok(Binding::new(hash)) - } + /// Returns [`RemoveError`] for locking, current-pointer or filesystem failures, including a + /// still-active generation. A filesystem failure can leave a partially removed directory. + #[tracing::instrument(skip_all, err, fields(generation = %id))] + pub(crate) fn remove(&self, id: GenerationId) -> Result<(), RemoveError> { + let _lock = self.lock()?; + if self.current()? == Some(id) { + return Err(RemoveError::Active(id)); + } - /// Runs `write` against the artifact's buffered staged file and binds the digest it returns. - /// - /// The streaming escape for artifacts whose bytes no single value serializes, so the binding - /// stays typed while the write stays free. - /// - /// # Errors - /// - /// Returns an error when creating or flushing the staged file fails or when `write` fails. - pub(crate) fn stage_with( - &self, - _artifact: A, - write: impl FnOnce(&mut BufWriter) -> io::Result, - ) -> io::Result> - where - A: Artifact, - { - let mut writer = BufWriter::new(self.create(&A::NAME)?); - let hash = write(&mut writer)?; - writer.flush()?; - - Ok(Binding::new(hash)) + fs::remove_dir_all(self.generation_path(id))?; + File::open(self.path())?.sync_all()?; + Ok(()) } /// Opens and verifies the published generation `id`. /// /// # Errors /// - /// Returns an error when the manifest disagrees with the staged file set or names a - /// generation that is already published. Serializing the metadata document returns an error - /// when it fails. A write, sync, permission, or rename failure returns an error as well. - pub(crate) fn seal( - self, - repository: &SaltRepository, - ) -> Result { - // Typed bindings pin every manifest name: the artifact set holds the names distinct - // and off the metadata document's own, so the expected set is the manifest's names - // verbatim and the seal re-checks neither fact. - let expected: BTreeSet = repository.files.files().map(|file| file.name).collect(); - - // Manifest names are valid `FileName`s by construction, so this - // loop reports a staged name that is not one as unlisted before - // any comparison. - let mut staged = BTreeSet::::new(); - for entry in fs::read_dir(&self.path).map_err(SealError::Io)? { - let name = entry.map_err(SealError::Io)?.file_name(); - match name - .to_str() - .and_then(|utf8| FileName::new(utf8.to_owned())) - { - Some(valid) => { - staged.insert(valid); - } - None => return Err(SealError::Unlisted { name }), - } - } - - if let Some(name) = expected.difference(&staged).next() { - return Err(SealError::Missing { name: name.clone() }); - } - if let Some(name) = staged.difference(&expected).next() { - return Err(SealError::Unlisted { - name: name.as_str().into(), - }); - } - - let document = serde_json::to_vec_pretty(repository).map_err(SealError::Document)?; - let id = GenerationId(document_digest(&document)); - - let destination = self.root.join(id.to_string()); - if destination.exists() { - return Err(SealError::AlreadyPublished(id)); - } - - self.persist(&document, &staged, &destination) - .map_err(SealError::Io)?; - - Ok(PublishedGeneration { id }) - } - - fn persist( - &self, - document: &[u8], - staged: &BTreeSet, - destination: impl AsRef, - ) -> io::Result<()> { - let destination = destination.as_ref(); - let mut file = File::create(self.path.join(METADATA_FILE))?; - file.write_all(document)?; - file.sync_all()?; - make_readonly(&file)?; - - for name in staged { - let file = File::open(self.path.join(name.as_str()))?; - file.sync_all()?; - make_readonly(&file)?; - } - File::open(&self.path)?.sync_all()?; - - fs::rename(&self.path, destination)?; - File::open(&self.root)?.sync_all()?; - - Ok(()) - } -} - -impl Drop for StagedGeneration { - fn drop(&mut self) { - // Sealing renames the staging away, so removing the stale - // staging path is then a no-op. - drop(fs::remove_dir_all(&self.path)); - } -} - -/// Digests a metadata document. -/// -/// The bytes' SHA-256 is the identity of the generation publishing them. -pub(crate) fn document_digest(bytes: &[u8]) -> Sha256Digest { - let mut hasher = Sha256::new(); - hasher.update(bytes); - hasher.finalize() -} - -/// A published generation's identity and directory. -#[derive(Debug)] -pub(crate) struct PublishedGeneration { - pub id: GenerationId, -} - -impl PublishedGeneration { - /// Returns the generation's identity. - #[inline] - #[must_use] - pub(crate) const fn id(&self) -> GenerationId { - self.id + /// Returns [`OpenError`] for an unpublished generation, an identity mismatch, an unparsable + /// document, or a read failure. + pub(crate) fn open(&self, id: GenerationId) -> Result { + Generation::open(self, id) } } diff --git a/libs/@local/graph/atlas/src/file/generation/open.rs b/libs/@local/graph/atlas/src/file/generation/open.rs index f8af691cc47..437cf1a5b48 100644 --- a/libs/@local/graph/atlas/src/file/generation/open.rs +++ b/libs/@local/graph/atlas/src/file/generation/open.rs @@ -1,62 +1,15 @@ //! Opening published generations for reading. -use core::{error::Error, fmt}; use std::{fs, io}; use camino::{Utf8Path, Utf8PathBuf}; -use super::{GenerationId, GenerationRoot, METADATA_FILE, document_digest}; +use super::{GenerationId, GenerationRoot, METADATA_FILE, OpenError}; use crate::{ file::{repository::FileName, salt::SaltRepository}, integrity::Sha256Digest, }; -/// Opening a published generation failed. -#[derive(Debug)] -pub enum OpenError { - /// The generation is not published in this root. - Unpublished(GenerationId), - /// The document's bytes do not hash to the generation id. - Identity { - /// The generation the caller asked for. - id: GenerationId, - /// What the document's bytes actually hash to. - actual: Sha256Digest, - }, - /// The document does not parse as a repository this module speaks. - Document(serde_json::Error), - /// Reading the document failed. - Io(io::Error), -} - -impl fmt::Display for OpenError { - fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Unpublished(id) => { - write!(fmt, "generation {id} is not published in this root") - } - Self::Identity { id, actual } => write!( - fmt, - "the metadata document of generation {id} hashes to {actual}", - ), - Self::Document(error) => { - write!(fmt, "the metadata document failed to deserialize: {error}") - } - Self::Io(error) => write!(fmt, "the metadata document failed to read: {error}"), - } - } -} - -impl Error for OpenError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Document(error) => Some(error), - Self::Io(error) => Some(error), - Self::Unpublished(_) | Self::Identity { .. } => None, - } - } -} - /// A published generation opened for reading. /// /// The accessors give the generation's identity, the directory, and the parsed metadata document. @@ -74,16 +27,16 @@ pub(crate) struct Generation { repository: SaltRepository, } -impl GenerationRoot { - /// Opens and verifies the published generation `id`. +impl Generation { + /// Opens and verifies the published generation `id` in `root`. /// /// # Errors /// /// Returns [`OpenError`] for an unpublished generation, an identity mismatch, an unparsable /// document, or a read failure. #[tracing::instrument(skip_all)] - pub(crate) fn open(&self, id: GenerationId) -> Result { - let path = self.generation_path(id); + pub(super) fn open(root: &GenerationRoot, id: GenerationId) -> Result { + let path = root.generation_path(id); let document = match fs::read(path.join(METADATA_FILE)) { Ok(document) => document, @@ -93,22 +46,20 @@ impl GenerationRoot { Err(error) => return Err(OpenError::Io(error)), }; - let actual = document_digest(&document); + let actual = Sha256Digest::of(&document); if actual != id.digest() { return Err(OpenError::Identity { id, actual }); } let repository = serde_json::from_slice(&document).map_err(OpenError::Document)?; - Ok(Generation { + Ok(Self { id, path, repository, }) } -} -impl Generation { /// Returns the generation's identity. #[inline] #[must_use] diff --git a/libs/@local/graph/atlas/src/file/generation/scratch.rs b/libs/@local/graph/atlas/src/file/generation/scratch.rs new file mode 100644 index 00000000000..67e8d0131de --- /dev/null +++ b/libs/@local/graph/atlas/src/file/generation/scratch.rs @@ -0,0 +1,56 @@ +//! Transient per-run scratch directories, dropped when their handle goes out of scope. + +use std::{ + fs::{self, File}, + io, +}; + +use camino::Utf8PathBuf; + +/// A dot-prefixed directory for one run's transient working state. +/// +/// Dropping the handle removes the whole directory. +#[derive(Debug)] +#[clippy::has_significant_drop] +pub(crate) struct ScratchDirectory { + path: Utf8PathBuf, +} + +impl ScratchDirectory { + /// Adopts an existing directory as a scratch root. + /// + /// Dropping the value removes the directory and everything inside. + pub(crate) const fn new(path: Utf8PathBuf) -> Self { + Self { path } + } + + /// Creates (or reuses) a named subdirectory and returns its path. + /// + /// # Errors + /// + /// Returns an error when creating the subdirectory fails. + pub(crate) fn directory(&self, name: &str) -> io::Result { + let path = self.path.join(name); + fs::create_dir_all(&path)?; + + Ok(path) + } + + /// Creates a named file directly under the scratch root and returns it with its path. + /// + /// # Errors + /// + /// Returns an error when creating the scratch root or the file fails. + pub(crate) fn file(&self, name: &str) -> io::Result<(Utf8PathBuf, File)> { + let path = self.path.join(name); + fs::create_dir_all(&self.path)?; + + File::create(&path).map(|file| (path, file)) + } +} + +impl Drop for ScratchDirectory { + fn drop(&mut self) { + drop(fs::remove_dir_all(&self.path)); + } +} diff --git a/libs/@local/graph/atlas/src/file/generation/staging.rs b/libs/@local/graph/atlas/src/file/generation/staging.rs new file mode 100644 index 00000000000..15eb938dd2c --- /dev/null +++ b/libs/@local/graph/atlas/src/file/generation/staging.rs @@ -0,0 +1,214 @@ +//! Assembling one generation in a staging directory before it publishes. + +use alloc::collections::BTreeSet; +use std::{ + fs::{self, File}, + io::{self, BufWriter, Write as _}, +}; + +use camino::{Utf8Path, Utf8PathBuf}; + +use super::{GenerationId, METADATA_FILE, SealError}; +use crate::{ + file::{ + WriteAs, + repository::{Artifact, Binding, FileName}, + salt::SaltRepository, + }, + integrity::Sha256Digest, +}; + +/// Drops the write permission on a file about to publish. +/// +/// Published files reject write handles. Removal uses the containing directory's permissions. +fn make_readonly(file: &File) -> io::Result<()> { + let mut permissions = file.metadata()?.permissions(); + permissions.set_readonly(true); + file.set_permissions(permissions) +} + +/// A generation under assembly in a staging directory. +/// +/// Staged files publish by rename without copying. Dropping an unsealed staging removes it. +#[derive(Debug)] +#[clippy::has_significant_drop] +pub(crate) struct StagedGeneration { + root: Utf8PathBuf, + path: Utf8PathBuf, +} + +impl StagedGeneration { + /// Adopts a staging directory under `root`. + /// + /// Dropping the value removes the staging directory and everything inside. + pub(super) const fn new(root: Utf8PathBuf, path: Utf8PathBuf) -> Self { + Self { root, path } + } + + /// Creates (or truncates) a staged file for writing. + /// + /// # Errors + /// + /// Returns an error when creating the file fails. + pub(crate) fn create(&self, name: &FileName) -> io::Result { + File::create(self.path.join(name.as_str())) + } + + /// Returns the path of a staged file. + #[must_use] + pub(crate) fn path_of(&self, name: &FileName) -> Utf8PathBuf { + self.path.join(name.as_str()) + } + + /// Writes a value through its artifact's [`WriteAs`] implementation. + /// + /// The value writes itself into the artifact's pinned staged file through one buffered pass, + /// and the written bytes' digest binds to the artifact as the typed entry the seal + /// publishes. The values a given artifact accepts are its [`WriteAs`] impls. + /// + /// # Errors + /// + /// Returns an error when creating or flushing the staged file fails, and the value's own + /// error when its write fails. + #[expect( + unused_variables, + clippy::needless_pass_by_value, + reason = "used to signal the artifact's pinned name" + )] + pub(crate) fn stage(&self, artifact: A, value: V) -> Result, V::Error> + where + A: Artifact, + V: WriteAs>, + { + let mut writer = BufWriter::new(self.create(&A::NAME)?); + let hash = value.write_into(&mut writer)?; + writer.flush()?; + + Ok(Binding::new(hash)) + } + + /// Runs `write` against the artifact's buffered staged file and binds the digest it returns. + /// + /// # Errors + /// + /// Returns an error when creating or flushing the staged file fails or when `write` fails. + pub(crate) fn stage_with( + &self, + _artifact: A, + write: impl FnOnce(&mut BufWriter) -> io::Result, + ) -> io::Result> + where + A: Artifact, + { + let mut writer = BufWriter::new(self.create(&A::NAME)?); + let hash = write(&mut writer)?; + writer.flush()?; + + Ok(Binding::new(hash)) + } + + /// Seals the staging into a published generation. + /// + /// The staged file set must match the manifest exactly. Every file drops its write permission + /// before publication. A successful seal syncs every file and the staging directory before the + /// rename, then syncs the root directory. The returned generation is visible and durable. + /// + /// # Errors + /// + /// Returns an error when the manifest disagrees with the staged file set or names a + /// generation that is already published. Serializing the metadata document returns an error + /// when it fails. A write, sync, permission, or rename failure returns an error as well. + pub(crate) fn seal( + self, + repository: &SaltRepository, + ) -> Result { + // Artifact names are distinct and exclude the metadata document's name. + let expected: BTreeSet = repository.files.files().map(|file| file.name).collect(); + + let mut staged = BTreeSet::::new(); + for entry in fs::read_dir(&self.path).map_err(SealError::Io)? { + let name = entry.map_err(SealError::Io)?.file_name(); + match name + .to_str() + .and_then(|utf8| FileName::new(utf8.to_owned())) + { + Some(valid) => { + staged.insert(valid); + } + None => return Err(SealError::Unlisted { name }), + } + } + + if let Some(name) = expected.difference(&staged).next() { + return Err(SealError::Missing { name: name.clone() }); + } + if let Some(name) = staged.difference(&expected).next() { + return Err(SealError::Unlisted { + name: name.as_str().into(), + }); + } + + let document = serde_json::to_vec_pretty(repository).map_err(SealError::Document)?; + let id = GenerationId(Sha256Digest::of(&document)); + + let destination = self.root.join(id.to_string()); + if destination.exists() { + return Err(SealError::AlreadyPublished(id)); + } + + self.persist(&document, &staged, &destination) + .map_err(SealError::Io)?; + + Ok(PublishedGeneration { id }) + } + + fn persist( + &self, + document: &[u8], + staged: &BTreeSet, + destination: impl AsRef, + ) -> io::Result<()> { + let destination = destination.as_ref(); + let mut file = File::create(self.path.join(METADATA_FILE))?; + file.write_all(document)?; + file.sync_all()?; + make_readonly(&file)?; + + for name in staged { + let file = File::open(self.path.join(name.as_str()))?; + file.sync_all()?; + make_readonly(&file)?; + } + File::open(&self.path)?.sync_all()?; + + fs::rename(&self.path, destination)?; + File::open(&self.root)?.sync_all()?; + + Ok(()) + } +} + +impl Drop for StagedGeneration { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.path) + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!(path = %self.path, error = %error, "failed to remove staging directory"); + } + } +} + +/// The identity of a sealed generation. +#[derive(Debug)] +pub(crate) struct PublishedGeneration { + pub id: GenerationId, +} + +impl PublishedGeneration { + /// Returns the generation's identity. + #[inline] + #[must_use] + pub(crate) const fn id(&self) -> GenerationId { + self.id + } +} diff --git a/libs/@local/graph/atlas/src/file/generation/tests.rs b/libs/@local/graph/atlas/src/file/generation/tests.rs index 83ac720e582..84cc943e223 100644 --- a/libs/@local/graph/atlas/src/file/generation/tests.rs +++ b/libs/@local/graph/atlas/src/file/generation/tests.rs @@ -3,14 +3,19 @@ clippy::significant_drop_tightening, reason = "fixture stagings deliberately live to the end of their tests" )] -use core::{assert_matches, num::NonZero}; -use std::{fs, io::Write as _}; +use core::assert_matches; +use std::{ + fs::{self, File}, + io::{self, Read as _, Write as _}, + process::Command, +}; use camino::Utf8PathBuf; +use uuid::Uuid; use super::{ - ActivateError, CurrentError, GenerationId, GenerationRoot, METADATA_FILE, OpenError, SealError, - StagedGeneration, + ActivateError, CurrentError, GenerationId, GenerationRoot, LOCK_FILE, METADATA_FILE, OpenError, + RemoveError, ScratchDirectory, SealError, StagedGeneration, }; use crate::{ dataset::DatasetOrigin, @@ -54,7 +59,11 @@ fn scratch(name: &str) -> Utf8PathBuf { "hash-graph-atlas-generation-{}-{name}", std::process::id(), )); - let _: Result<(), std::io::Error> = fs::remove_dir_all(&dir); + if let Err(error) = fs::remove_dir_all(&dir) + && error.kind() != io::ErrorKind::NotFound + { + panic!("should remove the existing test directory: {error}"); + } dir } @@ -81,7 +90,7 @@ fn config(seed: u64) -> FitConfig { FitConfig { seed, selection: SelectionOptions { - maximum_count: NonZero::new(2).expect("the fixture capacity is nonzero"), + maximum_count: nz!(2), .. }, curve: AffinityCurve::new(positive!(1.577), positive!(0.895)), @@ -172,7 +181,7 @@ fn evidence() -> Evidence { landmarks: LandmarkEvidence { selected: 2, retained: 1, - layout_epochs: NonZero::new(5).expect("the fixture epoch count is nonzero"), + layout_epochs: nz!(5), }, policy: PolicyEvidence { relations: 1, @@ -205,7 +214,7 @@ fn evidence() -> Evidence { quad: QuadMeasurements { nodes: 1, leaves: 1, - depth: Depth::new(0).expect("the root depth is within the key width"), + depth: Depth::try_new(0).expect("the root depth is within the key width"), type_entries: 3, }, postings: PostingsMeasurements { @@ -243,7 +252,7 @@ fn stage_all(staging: &StagedGeneration, repository: &SaltRepository) { } #[test] -fn sealed_generation_is_complete_and_verifiable() { +fn seal_round_trip() { let root = GenerationRoot::new(scratch("publish")).expect("the root should open"); let repository = repository(); @@ -271,7 +280,7 @@ fn sealed_generation_is_complete_and_verifiable() { } #[test] -fn sealed_files_refuse_rewriting() { +fn seal_read_only() { let root = GenerationRoot::new(scratch("readonly")).expect("the root should open"); let repository = repository(); @@ -303,7 +312,7 @@ fn sealed_files_refuse_rewriting() { } #[test] -fn seal_rejects_a_manifest_the_staging_disagrees_with() { +fn seal_manifest_mismatch() { let root = GenerationRoot::new(scratch("mismatch")).expect("the root should open"); let repository = repository(); @@ -336,7 +345,7 @@ fn seal_rejects_a_manifest_the_staging_disagrees_with() { /// published - the document's digest is the identity, so identical content is the same /// generation rather than a new one. #[test] -fn identical_document_publishes_once() { +fn seal_duplicate_document() { let root = GenerationRoot::new(scratch("identical")).expect("the root should open"); let repository = repository(); @@ -353,7 +362,7 @@ fn identical_document_publishes_once() { } #[test] -fn activation_flips_the_pointer_and_supports_rollback() { +fn activate_rollback() { let root = GenerationRoot::new(scratch("activate")).expect("the root should open"); assert!( root.current() @@ -409,7 +418,7 @@ fn activation_flips_the_pointer_and_supports_rollback() { } #[test] -fn corrupt_pointer_is_rejected() { +fn current_corrupt_pointer() { let root = GenerationRoot::new(scratch("corrupt")).expect("the root should open"); fs::write(root.path.join("current"), "not a digest").expect("the pointer should write"); @@ -417,7 +426,7 @@ fn corrupt_pointer_is_rejected() { } #[test] -fn activated_generation_opens_verified() { +fn open_active_generation() { let root = GenerationRoot::new(scratch("open")).expect("the root should open"); let repository = repository(); @@ -447,7 +456,7 @@ fn activated_generation_opens_verified() { } #[test] -fn open_rejects_missing_tampered_and_foreign_documents() { +fn open_invalid_documents() { let root = GenerationRoot::new(scratch("open-reject")).expect("the root should open"); // An unpublished generation. @@ -487,7 +496,7 @@ fn open_rejects_missing_tampered_and_foreign_documents() { } #[test] -fn open_reports_a_retired_version_before_interpreting_the_body() { +fn open_version_precedence() { let root = GenerationRoot::new(scratch("open-version")).expect("the root should open"); // Serializing the repository preserves the field order required by version checking. @@ -510,7 +519,7 @@ fn open_reports_a_retired_version_before_interpreting_the_body() { let retired = broken.replace(r#""version":2"#, r#""version":1"#); let error = root .open(publish(&retired)) - .expect_err("a retired version is rejected"); + .expect_err("a retired version should fail"); assert!( error .to_string() @@ -521,7 +530,7 @@ fn open_reports_a_retired_version_before_interpreting_the_body() { // An accepted version exposes the same body's schema error. let error = root .open(publish(&broken)) - .expect_err("an invalid body is rejected"); + .expect_err("an invalid body should fail"); let message = error.to_string(); assert!( !message.contains("unsupported repository version"), @@ -530,7 +539,7 @@ fn open_reports_a_retired_version_before_interpreting_the_body() { } #[test] -fn dropped_staging_leaves_nothing_behind() { +fn staging_drop_cleanup() { let path = scratch("abandon"); let root = GenerationRoot::new(&path).expect("the root should open"); @@ -544,6 +553,186 @@ fn dropped_staging_leaves_nothing_behind() { .collect(); assert!( entries.is_empty(), - "an abandoned staging should be removed: {entries:?}" + "dropping an abandoned staging should leave no entries: {entries:?}" + ); +} + +fn root() -> (ScratchDirectory, GenerationRoot) { + let path = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .expect("the temporary directory should have a UTF-8 path") + .join(format!("atlas-generation-lock-{}", Uuid::now_v7())); + let scratch = ScratchDirectory::new(path.clone()); + let root = GenerationRoot::new(path).expect("the generation root should open"); + (scratch, root) +} + +/// Creates an empty directory whose identity repeats `byte`. +/// +/// The directory satisfies activation's existence check but has no metadata to open. +/// +/// # Panics +/// +/// Panics if directory creation fails. +fn publish(root: &GenerationRoot, byte: u8) -> GenerationId { + let id = format!("{byte:02x}") + .repeat(32) + .parse() + .expect("the hexadecimal fixture should name a generation"); + fs::create_dir_all(root.generation_path(id)).expect("the generation directory should create"); + id +} + +#[test] +fn remove_active() { + let (_scratch, root) = root(); + let id = publish(&root, 1); + root.activate(id).expect("the generation should activate"); + + let error = root + .remove(id) + .expect_err("the active generation should remain"); + assert_matches!(error, RemoveError::Active(active) if active == id); + assert!(root.generation_path(id).is_dir()); + assert_eq!(root.current().expect("the pointer should read"), Some(id)); +} + +#[test] +#[expect( + clippy::verbose_file_reads, + reason = "the test reads a retained descriptor after removing its path" +)] +fn remove_inactive() { + let (_scratch, root) = root(); + let retired = publish(&root, 1); + let active = publish(&root, 2); + root.activate(active) + .expect("the generation should activate"); + let path = root.generation_path(retired).join("artifact"); + fs::write(&path, "retained bytes").expect("the artifact should write"); + let mut reader = File::open(path).expect("the artifact should open"); + + root.remove(retired) + .expect("the inactive generation should remove"); + + assert!(!root.generation_path(retired).exists()); + assert_eq!( + root.current().expect("the pointer should read"), + Some(active) + ); + let mut content = String::new(); + reader + .read_to_string(&mut content) + .expect("the open descriptor should remain readable"); + assert_eq!(content, "retained bytes"); + assert_matches!(root.activate(retired), Err(ActivateError::Unpublished(id)) if id == retired); + assert_eq!( + root.current().expect("the pointer should read"), + Some(active) + ); +} + +#[test] +fn remove_corrupt_pointer() { + let (_scratch, root) = root(); + let id = publish(&root, 1); + fs::write(root.path().join("current"), "not a generation") + .expect("the corrupt pointer should write"); + + let error = root + .remove(id) + .expect_err("a corrupt pointer should prevent removal"); + + assert_matches!(error, RemoveError::Current(CurrentError::Corrupt(_))); + assert!(root.generation_path(id).is_dir()); +} + +#[test] +fn remove_missing() { + let (_scratch, root) = root(); + let id = publish(&root, 1); + root.remove(id) + .expect("an inactive generation should remove without a current pointer"); + + let error = root + .remove(id) + .expect_err("an absent directory should report its filesystem error"); + + assert_matches!(error, RemoveError::Io(error) if error.kind() == io::ErrorKind::NotFound); +} + +#[test] +fn lock_unavailable() { + let (_scratch, root) = root(); + let id = publish(&root, 1); + fs::create_dir_all(root.path().join(LOCK_FILE)) + .expect("the lock-path obstruction should create"); + + assert_matches!(root.activate(id), Err(ActivateError::Io(_))); + let error = root + .remove(id) + .expect_err("removal should require the root lock"); + assert_matches!(error, RemoveError::Io(_)); + assert!(root.generation_path(id).is_dir()); + assert_eq!( + root.current().expect("the absent pointer should read"), + None ); } + +/// Independently opened descriptors contend across processes and release on close. +#[test] +#[cfg_attr(miri, ignore = "Miri cannot spawn a second test process")] +fn lock_exclusion() { + const ROOT: &str = "HASH_ATLAS_LOCK_TEST_ROOT"; + const BLOCKED: &str = "HASH_ATLAS_LOCK_TEST_BLOCKED"; + if let Some(path) = std::env::var_os(ROOT) { + let file = File::options() + .read(true) + .write(true) + .open(std::path::Path::new(&path).join(LOCK_FILE)) + .expect("the parent's lock file should open independently"); + if std::env::var_os(BLOCKED).is_some() { + assert_matches!(file.try_lock(), Err(fs::TryLockError::WouldBlock)); + } else { + file.try_lock() + .expect("the closed descriptor should release its lock"); + } + return; + } + + let (_scratch, root) = root(); + let lock = root.lock().expect("the root lock should acquire"); + let child = |blocked: bool| { + let mut command = + Command::new(std::env::current_exe().expect("the test binary should resolve")); + command + .args([ + "--exact", + "file::generation::tests::lock_exclusion", + "--nocapture", + ]) + .env(ROOT, root.path()) + .env_remove(BLOCKED); + if blocked { + command.env(BLOCKED, "1"); + } + let output = command + .output() + .expect("the lock-checking child should execute"); + assert!( + output.status.success(), + "the child should satisfy the lock assertion: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "the child should execute its assertion" + ); + }; + + child(true); + drop(lock); + assert!(root.path().join(LOCK_FILE).is_file()); + child(false); +} diff --git a/libs/@local/graph/atlas/src/file/identity/mod.rs b/libs/@local/graph/atlas/src/file/identity/mod.rs index 697a71c8cc4..d202c69cb6b 100644 --- a/libs/@local/graph/atlas/src/file/identity/mod.rs +++ b/libs/@local/graph/atlas/src/file/identity/mod.rs @@ -54,7 +54,7 @@ #![expect( clippy::little_endian_bytes, reason = "the fields are little endian, while the magic discriminant stores native endian, so \ - a cross-endian reader fails loudly at the magic instead of misreading fields" + a cross-endian reader fails magic validation instead of misreading fields" )] use core::fmt; @@ -157,7 +157,7 @@ pub(crate) enum Version { zerocopy::KnownLayout, )] #[repr(u16)] -pub enum Kind { +pub(crate) enum Kind { /// Ontology types: the payload holds icons. Ontology = 0, /// Nodes: the payload holds labels. @@ -198,16 +198,40 @@ impl fmt::Display for Kind { zerocopy::KnownLayout, )] #[repr(u16)] -pub enum KeyKind { +pub(crate) enum KeyKind { /// An [`ArchivedOntologyTypeUuid`]. OntologyTypeUuid = 0x00_00, /// An [`ArchivedEntityId`]. EntityId = 0x00_01, /// A `u8`. + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "a declared key width of the identity format, which a reader accepts from a \ + header and no writer in this crate produces yet" + ) + )] U8Le = 0x01_00, /// A [`U16`]. + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "a declared key width of the identity format, which a reader accepts from a \ + header and no writer in this crate produces yet" + ) + )] U16Le = 0x01_01, /// A [`U64`]. + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "a declared key width of the identity format; the only dataset writing u64 \ + keys is `dataset::memory`, which is test-only" + ) + )] U64Le = 0x01_02, } diff --git a/libs/@local/graph/atlas/src/file/identity/read.rs b/libs/@local/graph/atlas/src/file/identity/read.rs index 7048f22a1f8..637aa3d065b 100644 --- a/libs/@local/graph/atlas/src/file/identity/read.rs +++ b/libs/@local/graph/atlas/src/file/identity/read.rs @@ -6,14 +6,17 @@ use std::path::Path; use zerocopy::FromBytes as _; use super::{FileHeader, KeyKind, Kind, PayloadSpan}; -use crate::file::region::{ - PAGE, - header::{HeaderError, HeaderMap}, +use crate::file::{ + ArtifactFile, + region::{ + PAGE, + header::{HeaderError, HeaderMap}, + }, }; /// Opening an identity file failed. #[derive(Debug)] -pub enum OpenIdentityError { +pub(crate) enum OpenIdentityError { /// Reading the header page failed. Header(HeaderError), /// The file length contradicts the header's geometry. @@ -75,7 +78,9 @@ pub(crate) struct IdentityFile { map: HeaderMap, } -impl IdentityFile { +impl ArtifactFile for IdentityFile { + type Error = OpenIdentityError; + /// Opens and maps the identity file at `path`. /// /// # Errors @@ -84,7 +89,7 @@ impl IdentityFile { /// [`OpenIdentityError::Length`] when the file length contradicts the header's geometry, and /// [`OpenIdentityError::Index`] when the index region is not an fst map. #[tracing::instrument(skip_all)] - pub(crate) fn open(path: impl AsRef) -> Result { + fn open(path: impl AsRef) -> Result { let map = HeaderMap::::open(path).map_err(OpenIdentityError::Header)?; let expected = map.header().expected_file_len(); @@ -99,7 +104,9 @@ impl IdentityFile { Ok(file) } +} +impl IdentityFile { /// Borrows the parsed header at the head of the mapping. #[inline] #[must_use] diff --git a/libs/@local/graph/atlas/src/file/identity/tests.rs b/libs/@local/graph/atlas/src/file/identity/tests.rs index 33a22b0e4d6..fbdcd978d03 100644 --- a/libs/@local/graph/atlas/src/file/identity/tests.rs +++ b/libs/@local/graph/atlas/src/file/identity/tests.rs @@ -24,7 +24,10 @@ use crate::{ auxiliary::{Icon, Label, OwnedLegend}, memory::{MemoryNodeId, MemoryOntologyId}, }, - file::region::{header::HeaderError, machine::Machine}, + file::{ + ArtifactFile as _, + region::{header::HeaderError, machine::Machine}, + }, identity::{NodeRowId, OntologyRowId}, }; diff --git a/libs/@local/graph/atlas/src/file/landmark/mod.rs b/libs/@local/graph/atlas/src/file/landmark/mod.rs index 6ce8b708eb2..aed4e9e2ee5 100644 --- a/libs/@local/graph/atlas/src/file/landmark/mod.rs +++ b/libs/@local/graph/atlas/src/file/landmark/mod.rs @@ -46,7 +46,7 @@ #![expect( clippy::little_endian_bytes, reason = "the fields are little endian, while the magic discriminant stores native endian, so \ - a cross-endian reader fails loudly at the magic instead of misreading fields" + a cross-endian reader fails magic validation instead of misreading fields" )] use core::fmt; diff --git a/libs/@local/graph/atlas/src/file/mod.rs b/libs/@local/graph/atlas/src/file/mod.rs index ca83a0ff3ad..434ddf81803 100644 --- a/libs/@local/graph/atlas/src/file/mod.rs +++ b/libs/@local/graph/atlas/src/file/mod.rs @@ -180,7 +180,7 @@ //! fields rather than as files of their own. Activation is one current-generation pointer above the //! versioned directories. -use std::io; +use std::{io, path::Path}; use crate::integrity::Sha256Digest; @@ -199,6 +199,32 @@ pub(crate) mod repository; pub(crate) mod salt; pub(crate) mod sprs; +/// A reader that opens one artifact format from a path. +/// +/// This is the read half of [`WriteInto`]. Implementations validate the format properties needed +/// to open their artifact, including its magic, layout version, and geometry. This contract does +/// not include verification against a repository digest. [`repository::Binding::open`] verifies a +/// bound artifact before opening it through this trait. +pub(crate) trait ArtifactFile { + /// Why a file of this format failed to open. + type Error: core::error::Error; + + /// Opens the file at `path` in this format. + /// + /// # Errors + /// + /// Returns [`Self::Error`] when the bytes at `path` are not a file this format accepts, and + /// when reading or mapping them fails. + fn open(path: impl AsRef) -> Result + where + Self: Sized; +} + +/// A reader for the file format of artifact `A`. +/// +/// See [`WriteAs`] for the corresponding writer marker. +pub(crate) trait OpenAs: ArtifactFile {} + /// A value that writes itself as one artifact stream and names the written bytes. /// /// The digest is the SHA-256 of exactly the bytes written, in one pass - the identity the @@ -250,7 +276,7 @@ pub(crate) fn digest_file(path: impl AsRef) -> io::Result Fenceposts { /// Returns the exclusive upper bound of the position domain: one past the last position. #[inline] #[must_use] + #[cfg(test)] // used in `salt::lod` to verify coverage pub(crate) fn bound(&self) -> I { self.post(POSTS - 1) } diff --git a/libs/@local/graph/atlas/src/file/morton/read.rs b/libs/@local/graph/atlas/src/file/morton/read.rs index e8aa525d057..3fe612cb894 100644 --- a/libs/@local/graph/atlas/src/file/morton/read.rs +++ b/libs/@local/graph/atlas/src/file/morton/read.rs @@ -8,9 +8,12 @@ use zerocopy::{FromBytes as _, LE, U64}; use super::{FencepostError, Fenceposts, FileHeader}; use crate::{ - file::region::{ - PAGE, - header::{HeaderError, HeaderMap}, + file::{ + ArtifactFile, + region::{ + PAGE, + header::{HeaderError, HeaderMap}, + }, }, identity::BasePosition, morton::{Depth, MortonCell, MortonKey}, @@ -18,7 +21,7 @@ use crate::{ /// Opening a morton file failed. #[derive(Debug)] -pub enum OpenMortonError { +pub(crate) enum OpenMortonError { /// Reading the header page failed. Header(HeaderError), /// The header's fenceposts break a structural rule. @@ -86,7 +89,9 @@ pub(crate) struct MortonFile { fenceposts: Fenceposts, } -impl MortonFile { +impl ArtifactFile for MortonFile { + type Error = OpenMortonError; + /// Opens and maps the morton file at `path`. /// /// # Errors @@ -95,7 +100,7 @@ impl MortonFile { /// [`OpenMortonError::Fenceposts`] when the header's fenceposts break a structural rule, and /// [`OpenMortonError::Length`] when the file length contradicts the header's geometry. #[tracing::instrument(skip_all)] - pub(crate) fn open(path: impl AsRef) -> Result { + fn open(path: impl AsRef) -> Result { let map = HeaderMap::::open(path).map_err(OpenMortonError::Header)?; let header = map.header(); @@ -113,7 +118,9 @@ impl MortonFile { Ok(Self { map, fenceposts }) } +} +impl MortonFile { /// Borrows the parsed header at the head of the mapping. #[inline] #[must_use] @@ -159,7 +166,7 @@ impl MortonFile { clippy::cast_possible_truncation, reason = "fencepost indices are bounded by the 34 posts" )] - Depth::new(segment as u8 - 1).expect("every segment index names a valid depth") + Depth::try_new(segment as u8 - 1).expect("every segment index names a valid depth") } /// Views the index keys: one key per stride of codes. diff --git a/libs/@local/graph/atlas/src/file/morton/tests.rs b/libs/@local/graph/atlas/src/file/morton/tests.rs index a58cfaa8e84..3a965297409 100644 --- a/libs/@local/graph/atlas/src/file/morton/tests.rs +++ b/libs/@local/graph/atlas/src/file/morton/tests.rs @@ -21,14 +21,17 @@ use super::{ write::{PAGE_STRIDE, write_regions}, }; use crate::{ - file::region::{PAGE_BYTES, header::HeaderError, machine::Machine}, + file::{ + ArtifactFile as _, + region::{PAGE_BYTES, header::HeaderError, machine::Machine}, + }, identity::BasePosition, morton::{Depth, MortonCell, MortonKey}, }; /// A subdivision depth, panicking on a fixture outside the documented domain. fn depth(value: u8) -> Depth { - Depth::new(value).expect("test depths lie within the documented domain") + Depth::try_new(value).expect("test depths lie within the documented domain") } /// Fenceposts holding `lengths.len()` leading segments and empty ones behind them. diff --git a/libs/@local/graph/atlas/src/file/policy/mod.rs b/libs/@local/graph/atlas/src/file/policy/mod.rs index ea1e2202375..df57b817b45 100644 --- a/libs/@local/graph/atlas/src/file/policy/mod.rs +++ b/libs/@local/graph/atlas/src/file/policy/mod.rs @@ -37,7 +37,7 @@ #![expect( clippy::little_endian_bytes, reason = "the fields are little endian, while the magic discriminant stores native endian, so \ - a cross-endian reader fails loudly at the magic instead of misreading fields" + a cross-endian reader fails magic validation instead of misreading fields" )] use core::fmt; diff --git a/libs/@local/graph/atlas/src/file/postings/mod.rs b/libs/@local/graph/atlas/src/file/postings/mod.rs index b102f28d2fe..f14dec49eee 100644 --- a/libs/@local/graph/atlas/src/file/postings/mod.rs +++ b/libs/@local/graph/atlas/src/file/postings/mod.rs @@ -89,7 +89,7 @@ #![expect( clippy::little_endian_bytes, reason = "the fields are little endian, while the magic discriminant stores native endian, so \ - a cross-endian reader fails loudly at the magic instead of misreading fields" + a cross-endian reader fails magic validation instead of misreading fields" )] use core::fmt; diff --git a/libs/@local/graph/atlas/src/file/postings/read.rs b/libs/@local/graph/atlas/src/file/postings/read.rs index 8a295b87c39..60c7e9a72da 100644 --- a/libs/@local/graph/atlas/src/file/postings/read.rs +++ b/libs/@local/graph/atlas/src/file/postings/read.rs @@ -19,7 +19,7 @@ use crate::{ /// Opening a postings file failed. #[derive(Debug)] -pub enum OpenPostingsError { +pub(crate) enum OpenPostingsError { /// Reading the header page failed. Header(HeaderError), /// The file length contradicts the header's geometry. diff --git a/libs/@local/graph/atlas/src/file/quad/mod.rs b/libs/@local/graph/atlas/src/file/quad/mod.rs index 9952b464c47..a1e6cc6a28e 100644 --- a/libs/@local/graph/atlas/src/file/quad/mod.rs +++ b/libs/@local/graph/atlas/src/file/quad/mod.rs @@ -73,10 +73,12 @@ #![expect( clippy::little_endian_bytes, reason = "the fields are little endian, while the magic discriminant stores native endian, so \ - a cross-endian reader fails loudly at the magic instead of misreading fields" + a cross-endian reader fails magic validation instead of misreading fields" )] -use core::{fmt, ops::Range}; +use core::fmt; +#[cfg(test)] +use core::ops::Range; use zerocopy::{LE, U32, U64, Unalign}; @@ -252,6 +254,7 @@ impl Node { /// Base delivery positions of the points this node's tile delivers first. #[inline] #[must_use] + #[cfg(test)] pub(crate) const fn run(&self) -> Range { let start = self.start.get(); start..start + self.length.get() as u64 diff --git a/libs/@local/graph/atlas/src/file/quad/read.rs b/libs/@local/graph/atlas/src/file/quad/read.rs index a8962e27d48..cf0173b4692 100644 --- a/libs/@local/graph/atlas/src/file/quad/read.rs +++ b/libs/@local/graph/atlas/src/file/quad/read.rs @@ -8,17 +8,17 @@ use zerocopy::U32; use zerocopy::{FromBytes as _, LE, U64}; use super::{FileHeader, Node}; -use crate::{ - file::region::{ +use crate::file::{ + ArtifactFile, + region::{ PAGE, header::{HeaderError, HeaderMap}, }, - morton::MortonCell, }; /// Opening a quad file failed. #[derive(Debug)] -pub enum OpenQuadError { +pub(crate) enum OpenQuadError { /// Reading the header page failed. Header(HeaderError), /// The node count leaves no room for the absent-child sentinel. @@ -97,7 +97,9 @@ pub(crate) struct QuadFile { map: HeaderMap, } -impl QuadFile { +impl ArtifactFile for QuadFile { + type Error = OpenQuadError; + /// Opens and maps the quad file at `path`. /// /// # Errors @@ -108,7 +110,7 @@ impl QuadFile { /// [`OpenQuadError::Posts`] when a type-set fencepost breaks a structural rule, and /// [`OpenQuadError::Child`] when a child index escapes the table or fails to point deeper. #[tracing::instrument(skip_all)] - pub(crate) fn open(path: impl AsRef) -> Result { + fn open(path: impl AsRef) -> Result { let map = HeaderMap::::open(path).map_err(OpenQuadError::Header)?; let header = map.header(); @@ -157,7 +159,9 @@ impl QuadFile { Ok(this) } +} +impl QuadFile { /// Borrows the parsed header at the head of the mapping. #[inline] #[must_use] @@ -235,28 +239,4 @@ impl QuadFile { usize::try_from(posts[node + 1].get()).expect("a mapped region fits the address space"); &self.ids()[start..end] } - - /// Returns the node owning `cell`. - /// - /// [`None`] when the schedule delivers nothing new below the cell's deepest ancestor node. - /// - /// The walk consumes the two-bit digits of the cell's key prefix from the root: digit `d` names - /// the Morton child quadrant at depth `d + 1`. An empty table locates nothing. - #[must_use] - pub(crate) fn locate(&self, cell: MortonCell) -> Option { - let nodes = self.nodes(); - if nodes.is_empty() { - return None; - } - - let mut node = 0_u32; - let prefix = cell.min_key().prefix(cell.depth()); - for step in (0..cell.depth().get()).rev() { - let quadrant = (prefix >> (2 * u64::from(step))) & 0b11; - let record = &nodes[node as usize]; - node = record.child(quadrant as usize)?; - } - - Some(node) - } } diff --git a/libs/@local/graph/atlas/src/file/quad/tests.rs b/libs/@local/graph/atlas/src/file/quad/tests.rs index 8f9c194a6cd..2f7851a4799 100644 --- a/libs/@local/graph/atlas/src/file/quad/tests.rs +++ b/libs/@local/graph/atlas/src/file/quad/tests.rs @@ -19,19 +19,11 @@ use super::{ read::{OpenQuadError, QuadFile}, write::write_regions, }; -use crate::{ - file::region::{PAGE_BYTES, header::HeaderError, machine::Machine}, - morton::{Depth, MortonCell}, +use crate::file::{ + ArtifactFile as _, + region::{PAGE_BYTES, header::HeaderError, machine::Machine}, }; -fn depth(value: u8) -> Depth { - Depth::new(value).expect("test depths lie within the documented domain") -} - -fn cell(depth_value: u8, x: u32, y: u32) -> MortonCell { - MortonCell::new(depth(depth_value), x, y).expect("test cells lie within the depth's grid") -} - /// A per-test scratch file path under the system temp directory. fn scratch(name: &str) -> PathBuf { let dir = @@ -212,33 +204,7 @@ fn written_regions_reopen_verbatim() { } } -#[test] -fn locate_walks_the_prefix_digits() { - let path = scratch("locate.quad"); - fs::write(&path, fixture_bytes()).expect("the scratch file is writable"); - let file = QuadFile::open(&path).expect("the written file reopens"); - - // The root owns the whole-domain cell. - assert_eq!(file.locate(cell(0, 0, 0)), Some(0)); - - // Depth 1: quadrants 0 and 2 have nodes, 1 and 3 do not. - assert_eq!(file.locate(cell(1, 0, 0)), Some(1)); - assert_eq!(file.locate(cell(1, 0, 1)), Some(2)); - assert_eq!(file.locate(cell(1, 1, 0)), None); - assert_eq!(file.locate(cell(1, 1, 1)), None); - - // Node 3 sits in quadrant 1 (x1y0) of node 2's cell (0, 1): its - // depth-2 grid coordinates are (2*0 + 1, 2*1 + 0) = (1, 2). - assert_eq!(file.locate(cell(2, 1, 2)), Some(3)); - - // Sibling quadrants of node 3 have no nodes. - assert_eq!(file.locate(cell(2, 0, 2)), None); - - // Below a leaf nothing locates. - assert_eq!(file.locate(cell(2, 0, 0)), None); - assert_eq!(file.locate(cell(3, 2, 4)), None); -} - +/// An empty tree is valid geometry: it writes and reopens with no nodes at all. #[test] fn empty_tree_reopens() { let path = scratch("empty.quad"); @@ -249,7 +215,6 @@ fn empty_tree_reopens() { let file = QuadFile::open(&path).expect("the empty file reopens"); assert!(file.nodes().is_empty()); - assert_eq!(file.locate(cell(0, 0, 0)), None); } #[test] @@ -348,20 +313,6 @@ fn open_rejects_malformed_posts_and_children() { ); } -/// Reference locate: the same prefix-digit walk over the in-memory table. -fn locate_reference(nodes: &[Node], cell: MortonCell) -> Option { - if nodes.is_empty() { - return None; - } - let mut node = 0_u32; - let prefix = cell.min_key().prefix(cell.depth()); - for step in (0..cell.depth().get()).rev() { - let quadrant = (prefix >> (2 * u64::from(step))) & 0b11; - node = nodes[node as usize].child(quadrant as usize)?; - } - Some(node) -} - /// Every valid table and set cover roundtrips verbatim. #[property_test] fn written_tables_roundtrip( @@ -376,8 +327,6 @@ fn written_tables_roundtrip( 0..12, )] seeds: Vec<(u64, [Option; 4], u8)>, - probe: u64, - #[strategy = 0_u8..=4] probe_depth: u8, ) { let count = seeds.len(); let nodes: Vec = seeds @@ -417,7 +366,4 @@ fn written_tables_roundtrip( let stored: Vec = file.type_set(index).iter().map(|id| id.get()).collect(); prop_assert_eq!(stored, sets.set(node), "node {}'s set", node); } - - let cell = crate::morton::MortonKey::from_bits(probe).cell(depth(probe_depth)); - prop_assert_eq!(file.locate(cell), locate_reference(&nodes, cell)); } diff --git a/libs/@local/graph/atlas/src/file/region/header.rs b/libs/@local/graph/atlas/src/file/region/header.rs index 92aa275ab60..aa77b0c40a6 100644 --- a/libs/@local/graph/atlas/src/file/region/header.rs +++ b/libs/@local/graph/atlas/src/file/region/header.rs @@ -268,8 +268,8 @@ impl HeaderMap { /// rather than a second acceptance path. #[expect( clippy::host_endian_bytes, - reason = "the version word persists a `#[repr(u32)]` enum, whose bytes are native by \ - construction, so the diagnostic reads them exactly as the refused parse did" + reason = "the version word persists a `#[repr(u32)]` enum with native-endian bytes. The \ + diagnostic reads them exactly as the refused parse did" )] fn try_recover_error(page: &[u8]) -> HeaderError { let (magic, rest) = page.split_at(size_of::()); diff --git a/libs/@local/graph/atlas/src/file/region/tests.rs b/libs/@local/graph/atlas/src/file/region/tests.rs index 209b832f8cd..a9d20e6f0f5 100644 --- a/libs/@local/graph/atlas/src/file/region/tests.rs +++ b/libs/@local/graph/atlas/src/file/region/tests.rs @@ -1,3 +1,6 @@ +//! Certificates for the region layer every artifact format shares. + +use core::assert_matches; use std::fs; use super::{PAGE, PageMap, padded_size, write_padding, write_region}; @@ -90,8 +93,9 @@ fn live_map_excludes_exclusive_lockers() { let map = PageMap::open(&path).expect("the fixture file should map"); let writer = fs::File::open(&path).expect("the fixture file should reopen"); - assert!( - matches!(writer.try_lock(), Err(fs::TryLockError::WouldBlock)), + assert_matches!( + writer.try_lock(), + Err(fs::TryLockError::WouldBlock), "an exclusive lock must contend with a live mapping" ); diff --git a/libs/@local/graph/atlas/src/file/repository/mod.rs b/libs/@local/graph/atlas/src/file/repository/mod.rs index 49cac14c8b4..3b82a4a85a5 100644 --- a/libs/@local/graph/atlas/src/file/repository/mod.rs +++ b/libs/@local/graph/atlas/src/file/repository/mod.rs @@ -26,7 +26,7 @@ use std::path::Path; use camino::{Utf8Path, Utf8PathBuf}; -use super::generation::Generation; +use super::{OpenAs, generation::Generation}; use crate::integrity::Sha256Digest; #[cfg(test)] @@ -49,7 +49,7 @@ impl core::error::Error for UnknownRepositoryVersion {} /// The entry names the file and carries the SHA-256 the publisher computed over its bytes. A /// verification hashes the file as it is on disk and compares. #[derive(Debug)] -pub enum IntegrityVerificationError { +pub(crate) enum IntegrityVerificationError { /// The file's bytes hash to a digest other than the recorded one. Checksum { /// The repository entry the file failed, holding its name and the recorded digest. @@ -259,7 +259,8 @@ impl AsRef for VerifiedUtf8PathBuf { /// One published file, identified by its name within the repository and the SHA-256 of its bytes. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct RepositoryFile { +pub(crate) struct RepositoryFile { + /// The file's name within its generation directory. pub name: FileName, /// The SHA-256 the publisher computed over the file's bytes. pub hash: Sha256Digest, @@ -311,6 +312,46 @@ pub(crate) trait Artifact { const NAME: FileName; } +/// Opening the artifact a binding certifies failed. +#[derive(Debug)] +pub(crate) enum OpenBindingError { + /// The verified file failed to open in the artifact's reader. + Artifact(E), + /// The file failed verification against the binding's digest. + Integrity(IntegrityVerificationError), +} + +const impl From for OpenBindingError { + /// Wraps a verification failure as [`OpenBindingError::Integrity`]. + fn from(error: IntegrityVerificationError) -> Self { + Self::Integrity(error) + } +} + +impl fmt::Display for OpenBindingError +where + E: fmt::Display, +{ + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Artifact(error) => write!(fmt, "the verified file failed to open: {error}"), + Self::Integrity(error) => write!(fmt, "the file failed verification: {error}"), + } + } +} + +impl core::error::Error for OpenBindingError +where + E: core::error::Error + 'static, +{ + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::Artifact(error) => Some(error), + Self::Integrity(error) => Some(error), + } + } +} + /// A repository binding typed by the artifact it certifies. /// /// The value is the digest alone. The file name derives from `A`. Two artifacts' bindings are @@ -363,6 +404,25 @@ where hash: self.hash, } } + + /// Verifies the bound file in `generation` and opens it as `F`. + /// + /// The type bound admits only a reader the crate has declared an admitted reader of `A`. The + /// artifact a binding certifies and the reader it opens with cannot drift apart. The bytes are + /// verified against the binding's digest first, and the reader never sees a file that failed. + /// + /// # Errors + /// + /// Returns [`OpenBindingError::Integrity`] when the file is missing, unreadable, or hashes + /// to another digest, and [`OpenBindingError::Artifact`] when the verified bytes are not a + /// file the reader accepts. + pub(crate) fn open(&self, generation: &Generation) -> Result> + where + F: OpenAs, + { + let path = self.file().verify(generation)?; + F::open(path).map_err(OpenBindingError::Artifact) + } } impl fmt::Debug for Binding diff --git a/libs/@local/graph/atlas/src/file/salt/artifact.rs b/libs/@local/graph/atlas/src/file/salt/artifact.rs index 2448ea1ed39..2695c14b36d 100644 --- a/libs/@local/graph/atlas/src/file/salt/artifact.rs +++ b/libs/@local/graph/atlas/src/file/salt/artifact.rs @@ -12,13 +12,20 @@ use crate::{ file::{ - WriteAs, + OpenAs, WriteAs, array::SizedColumn, generation::METADATA_FILE, + identity, + morton::read::MortonFile, + quad::read::QuadFile, repository::{Artifact, FileName}, }, - identity::{BasePosition, ImportanceRank, NodeRowId}, + identity::{BasePosition, Column, EdgeRowId, ImportanceRank, NodeRowId, OntologyRowId}, math::Vec2, + salt::{ + adjacency::AdjacencyArchive, fit::prepare::identity::IdentityTableArchive, + postings::artifact::PostingsArchive, + }, }; /// Declares one artifact marker with its pinned file name. @@ -124,3 +131,17 @@ impl WriteAs for SizedColumn {} impl WriteAs for SizedColumn {} impl WriteAs for SizedColumn {} impl WriteAs for SizedColumn {} + +impl OpenAs for QuadFile {} +impl OpenAs for MortonFile {} +impl OpenAs for AdjacencyArchive {} +impl OpenAs for PostingsArchive {} +impl OpenAs for Column {} +impl OpenAs for Column {} +impl OpenAs for Column {} +impl OpenAs for Column {} +impl OpenAs for Column {} +impl OpenAs for Column {} +impl OpenAs for IdentityTableArchive {} +impl OpenAs for IdentityTableArchive {} +impl OpenAs for IdentityTableArchive {} diff --git a/libs/@local/graph/atlas/src/file/salt/metadata.rs b/libs/@local/graph/atlas/src/file/salt/metadata.rs index b64106ae565..260d7047e89 100644 --- a/libs/@local/graph/atlas/src/file/salt/metadata.rs +++ b/libs/@local/graph/atlas/src/file/salt/metadata.rs @@ -13,14 +13,13 @@ use hashql_core::id::Id as _; use crate::{ dataset::{DatasetOrigin, TemporalAxes}, - file::{generation::GenerationId, morton::SEGMENTS}, + file::generation::GenerationId, identity::{NodeRowId, OntologyRowId}, integrity::Sha256Digest, - math::{Bounds2, DNonNegative, DPositive, NonNegative, OpenUnitFraction, Similarity}, - morton::Depth, + math::{DNonNegative, DPositive, NonNegative, OpenUnitFraction, Similarity}, salt::{ embedding::{CardEmbeddingStats, EmbedderFingerprint}, - fit::{FitConfig, FitConfigDef, prepare::norm::NormSpotCheck}, + fit::{FitConfig, prepare::norm::NormSpotCheck}, importance::RankingConfig, knn::recall::RecallSpotCheck, ladder::paired::PairedMovementEvidence, @@ -111,7 +110,6 @@ pub(crate) struct Reproducibility { /// A replay takes its configuration from this echo, not from the defaults compiled into the /// replaying binary. Validated fields deserialize through their validating constructors, so a /// tampered echo refuses to parse. - #[serde(with = "FitConfigDef")] pub config: FitConfig, /// The embedding contract under which the embedder produced the card embeddings. pub embedder: EmbedderFingerprint, @@ -147,16 +145,12 @@ pub(crate) struct Evidence { /// With the fit and holdout measurements when this run fitted it. pub classifier: ClassifierEvidence, /// The relation build's dropped-instance and pruned-mass account. - #[serde(with = "BuildMeasurementsDef")] pub relations: BuildMeasurements, /// The level-of-detail stage's publish measurements. - #[serde(with = "LodMeasurementsDef")] pub lod: LodMeasurements, /// The quadtree build's publish measurements. - #[serde(with = "QuadMeasurementsDef")] pub quad: QuadMeasurements, /// The postings build's publish measurements. - #[serde(with = "PostingsMeasurementsDef")] pub postings: PostingsMeasurements, /// The projector training and ladder measurements. /// @@ -498,7 +492,6 @@ pub(crate) struct StepEvidence { /// The similarity aligning the step's field onto the baseline field. /// /// The identity for the baseline itself. - #[serde(with = "similarity")] pub alignment: Similarity, /// RMS movement against the baseline field after alignment. pub baseline_movement: DNonNegative, @@ -506,216 +499,6 @@ pub(crate) struct StepEvidence { pub adjacent_movement: DNonNegative, } -/// Serializes a [`Similarity`] as its decomposed coefficients. -/// -/// Validates through [`Similarity::new`] on deserialize. -mod similarity { - use serde::{Deserialize as _, Serialize as _, de::Error as _}; - - use crate::math::{Positive, Rotation, Similarity, Vec2}; - - /// The alignment's wire form. - /// - /// The rotation is its unit vector. - #[derive(serde::Serialize, serde::Deserialize)] - struct Record { - scale: Positive, - rotation: [f32; 2], - translation: [f32; 2], - } - - pub(super) fn serialize(alignment: &Similarity, serializer: S) -> Result - where - S: serde::Serializer, - { - Record { - scale: alignment.scale(), - rotation: [alignment.rotation().cos(), alignment.rotation().sin()], - translation: [alignment.translation().x(), alignment.translation().y()], - } - .serialize(serializer) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let Record { - scale, - rotation: [cos, sin], - translation: [x, y], - } = Record::deserialize(deserializer)?; - - // The rotation's unit-circle contract admits the rounding a - // fitted alignment carries and nothing more. - let unit_defect = f64::from(cos).mul_add(f64::from(cos), f64::from(sin) * f64::from(sin)); - if !((unit_defect - 1.0).abs() <= 1.0e-6 && x.is_finite() && y.is_finite()) { - return Err(D::Error::custom(format_args!( - "the rotation ({cos}, {sin}) does not lie on the unit circle or the translation \ - ({x}, {y}) is not finite" - ))); - } - - Similarity::new(scale, Rotation::from_cos_sin(cos, sin), Vec2::new(x, y)).ok_or_else(|| { - D::Error::custom(format_args!( - "the scale {scale} or its reciprocal is not a strictly positive normal number" - )) - }) - } -} - -/// Serializes a [`Bounds2`] as its corner coordinates. -/// -/// Validates through [`Bounds2::new`] on deserialize. -mod bounds2 { - use serde::{Deserialize as _, Serialize as _, de::Error as _}; - - use crate::math::{Bounds2, Vec2}; - - /// The frame's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct Record { - min: [f32; 2], - max: [f32; 2], - } - - pub(super) fn serialize(bounds: &Bounds2, serializer: S) -> Result - where - S: serde::Serializer, - { - Record { - min: [bounds.min().x(), bounds.min().y()], - max: [bounds.max().x(), bounds.max().y()], - } - .serialize(serializer) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let Record { min, max } = Record::deserialize(deserializer)?; - Bounds2::new(Vec2::new(min[0], min[1]), Vec2::new(max[0], max[1])).ok_or_else(|| { - D::Error::custom( - "the corners do not form a frame; both must be finite with min <= max per axis", - ) - }) - } -} - -/// Serializes the bucket histogram as a plain sequence. -/// -/// Validates the segment count on deserialize. -mod bucket_histogram { - use serde::{Deserialize as _, Serialize as _, de::Error as _}; - - use crate::file::morton::SEGMENTS; - - pub(super) fn serialize( - histogram: &[u64; SEGMENTS], - serializer: S, - ) -> Result - where - S: serde::Serializer, - { - histogram.as_slice().serialize(serializer) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<[u64; SEGMENTS], D::Error> - where - D: serde::Deserializer<'de>, - { - let lengths = Vec::::deserialize(deserializer)?; - let count = lengths.len(); - lengths.try_into().map_err(|_lengths| { - D::Error::custom(format_args!( - "the histogram holds {count} buckets where the schedule has {SEGMENTS}", - )) - }) - } -} - -/// serde shadow of [`LodMeasurements`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "LodMeasurements")] -struct LodMeasurementsDef { - #[serde(with = "bounds2")] - world: Bounds2, - #[serde(with = "bucket_histogram")] - bucket_histogram: [u64; SEGMENTS], - catch_all_population: u64, - co_location_excess: u64, - max_tile_delta: u64, -} - -/// serde shadow of [`BuildMeasurements`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "BuildMeasurements")] -struct BuildMeasurementsDef { - pruning_threshold: NonNegative, - retained_edges: usize, - pruned_edges: usize, - retained_mass: DNonNegative, - pruned_mass: DNonNegative, - self_references: usize, - multi_typed_edges: Vec, -} - -/// Serializes a [`Depth`] as its subdivision count. -/// -/// Validates through [`Depth::new`] on deserialize. -mod depth { - use serde::{Deserialize as _, de::Error as _}; - - use crate::morton::Depth; - - #[expect( - clippy::trivially_copy_pass_by_ref, - reason = "serde's `with` contract passes the field by reference" - )] - pub(super) fn serialize(depth: &Depth, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_u8(depth.get()) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = u8::deserialize(deserializer)?; - Depth::new(value).ok_or_else(|| { - D::Error::custom(format_args!( - "the depth {value} exceeds the {} subdivisions a 64-bit Morton key resolves", - Depth::MAX.get(), - )) - }) - } -} - -/// serde shadow of [`QuadMeasurements`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "QuadMeasurements")] -struct QuadMeasurementsDef { - nodes: u64, - leaves: u64, - #[serde(with = "depth")] - depth: Depth, - type_entries: u64, -} - -/// serde shadow of [`PostingsMeasurements`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "PostingsMeasurements")] -struct PostingsMeasurementsDef { - types: u64, - dense_types: u64, - list_entries: u64, - parent_edges: u64, - direct_entries: u64, -} - /// Scale record of the landmark stage. #[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct LandmarkEvidence { diff --git a/libs/@local/graph/atlas/src/file/salt/tests.rs b/libs/@local/graph/atlas/src/file/salt/tests.rs index 52697364da0..66cf4998ed6 100644 --- a/libs/@local/graph/atlas/src/file/salt/tests.rs +++ b/libs/@local/graph/atlas/src/file/salt/tests.rs @@ -24,8 +24,8 @@ use crate::{ identity::{NodeRowId, OntologyRowId}, integrity::{Sha256, Sha256Digest, Update as _}, math::{ - AffinityCurve, Bounds2, PositiveUnitFraction, Rotation, Similarity, UnitFraction, Vec2, - d_non_negative, d_positive, non_negative, open_unit_fraction, positive, unit_fraction, + AffinityCurve, Bounds2, Rotation, Similarity, Vec2, d_non_negative, d_positive, + non_negative, nz, open_unit_fraction, positive, positive_unit_fraction, unit_fraction, }, morton::Depth, salt::{ @@ -49,7 +49,7 @@ use crate::{ }, }, postings::build::PostingsMeasurements, - projector::train::TrainingSchedule, + projector::train::{TrainingSchedule, fit::TrainingScheduleOptions}, relation::BuildMeasurements, }, }; @@ -68,17 +68,14 @@ fn binding(seed: &str) -> Binding { /// Builds projector options with a short test schedule. fn placement() -> PlacementOptions { - let mut options = ProjectorOptions::ratified(); - options.schedule = TrainingSchedule::new( - NonZero::new(12).expect("the fixture step count is nonzero"), - 6, - NonZero::new(4).expect("the fixture cadence is nonzero"), - const { - PositiveUnitFraction::new(1.0e-3) - .expect("the fixture initial rate is a positive unit fraction") - }, - const { UnitFraction::new(1.0e-5).expect("the fixture minimum rate is a unit fraction") }, - ) + let mut options = ProjectorOptions::live(); + options.schedule = TrainingSchedule::new(TrainingScheduleOptions { + steps: nz!(12), + boundary: 6, + refresh_interval: nz!(4), + initial_learning_rate: positive_unit_fraction!(1.0e-3), + minimum_learning_rate: unit_fraction!(1.0e-5), + }) .expect("the fixture schedule is valid"); options.ladder = LadderOptions { conditions: Conditions::new(vec![non_negative!(0.0), non_negative!(1.0)]) @@ -103,8 +100,12 @@ fn config() -> FitConfig { overrides: vec![PolicyOverride { relation: OntologyRowId::new(7), source: PolicySource::Human, - distribution: Posterior::new([0.25, 0.5, 0.25]) - .expect("the fixture distribution sums to one"), + distribution: Posterior::new([ + unit_fraction!(0.25), + unit_fraction!(0.5), + unit_fraction!(0.25), + ]) + .expect("the fixture distribution sums to one"), }], .. }, @@ -325,7 +326,7 @@ fn evidence() -> Evidence { landmarks: LandmarkEvidence { selected: 4_096, retained: 1_024, - layout_epochs: NonZero::new(500).expect("the fixture epoch count is nonzero"), + layout_epochs: nz!(500), }, policy: PolicyEvidence { relations: 49, @@ -345,7 +346,7 @@ fn evidence() -> Evidence { quad: QuadMeasurements { nodes: 21_845, leaves: 16_000, - depth: Depth::new(7).expect("the fixture depth is within the key width"), + depth: Depth::try_new(7).expect("the fixture depth is within the key width"), type_entries: 65_000, }, postings: PostingsMeasurements { @@ -792,9 +793,11 @@ fn a_step_without_the_capped_estimand_decodes_as_absent() { #[test] fn tampered_configuration_echo_refuses_to_deserialize() { - // Each tampered value violates a construction invariant of its - // field's type; the validating deserialization is what turns the - // echo from a record into a contract. + let document = serde_json::to_value(repository()).expect("the repository should serialize"); + let decoded: SaltRepository = serde_json::from_value(document.clone()) + .expect("the unchanged repository should deserialize"); + assert_eq!(decoded, repository()); + for (pointer, tampered) in [ ( "/metadata/reproducibility/config/selection/retained_fraction", @@ -834,8 +837,8 @@ fn tampered_configuration_echo_refuses_to_deserialize() { serde_json::json!(100), ), ( - "/metadata/reproducibility/config/placement/projector/coefficients", - serde_json::json!([0.0, 1.0, 1.0, 1.0, 0.0, 1.0]), + "/metadata/reproducibility/config/placement/projector/coefficients/semantic", + serde_json::json!(0.0), ), // Each step must exceed the one before it, from the exact zero // baseline up. @@ -857,9 +860,20 @@ fn tampered_configuration_echo_refuses_to_deserialize() { "/metadata/evidence/projector/ladder/steps/1/alignment/scale", serde_json::json!(0.0), ), + ( + "/metadata/evidence/projector/ladder/steps/1/alignment/scale", + serde_json::json!(f32::MAX), + ), + ( + "/metadata/evidence/projector/ladder/steps/1/alignment/translation", + serde_json::json!([1.0e40, 0.0]), + ), + ( + "/metadata/evidence/lod/world/min", + serde_json::json!([9.0, -2.0]), + ), ] { - let mut document = - serde_json::to_value(repository()).expect("the repository should serialize"); + let mut document = document.clone(); *document .pointer_mut(pointer) .expect("the tampered field should exist in the document") = tampered; diff --git a/libs/@local/graph/atlas/src/file/sprs/mod.rs b/libs/@local/graph/atlas/src/file/sprs/mod.rs index 7615a63b792..dd945a8270c 100644 --- a/libs/@local/graph/atlas/src/file/sprs/mod.rs +++ b/libs/@local/graph/atlas/src/file/sprs/mod.rs @@ -57,7 +57,7 @@ #![expect( clippy::little_endian_bytes, reason = "the fields are little endian, while the magic discriminant stores native endian, so \ - a cross-endian reader fails loudly at the magic instead of misreading fields" + a cross-endian reader fails magic validation instead of misreading fields" )] use core::fmt; @@ -165,7 +165,7 @@ pub(crate) enum Version { zerocopy::Unaligned, )] #[repr(u8)] -pub enum IndexVariant { +pub(crate) enum IndexVariant { U16 = 0x00, U32 = 0x01, U64 = 0x02, @@ -210,7 +210,7 @@ impl IndexVariant { zerocopy::Unaligned, )] #[repr(u8)] -pub enum ValueTag { +pub(crate) enum ValueTag { Opaque = 0x00, U8 = 0x01, U16 = 0x02, diff --git a/libs/@local/graph/atlas/src/file/sprs/read.rs b/libs/@local/graph/atlas/src/file/sprs/read.rs index 9275151adc1..9e82536adc2 100644 --- a/libs/@local/graph/atlas/src/file/sprs/read.rs +++ b/libs/@local/graph/atlas/src/file/sprs/read.rs @@ -15,7 +15,7 @@ use crate::file::region::{ /// Opening a sparse matrix file failed. #[derive(Debug)] -pub enum OpenSprsError { +pub(crate) enum OpenSprsError { /// Reading the header page failed. Header(HeaderError), /// The file length contradicts the header's geometry. @@ -72,7 +72,7 @@ impl Error for OpenSprsError { /// Viewing an opened file's matrix failed. #[derive(Debug)] -pub enum SprsMatrixError { +pub(crate) enum SprsMatrixError { /// The file stores different element types than the requested ones. Elements { /// The value type the file stores. @@ -266,7 +266,7 @@ impl SprsFile { let Some(values) = N::view_region( values, usize::try_from(entries) - .expect("the index region maps, so the entry count fits the address space"), + .expect("the mapped index region's entry count fits the address space"), ) else { return Err(SprsMatrixError::Domain { value: N::TAG }); }; diff --git a/libs/@local/graph/atlas/src/identity/column.rs b/libs/@local/graph/atlas/src/identity/column.rs index 31131b1d6ee..776a69132c0 100644 --- a/libs/@local/graph/atlas/src/identity/column.rs +++ b/libs/@local/graph/atlas/src/identity/column.rs @@ -1,11 +1,59 @@ //! Validated column views over array artifacts. -use core::marker::PhantomData; +use core::{error::Error, fmt, marker::PhantomData}; +use std::path::Path; use hashql_core::id::{Id, IdSlice}; use zerocopy::{FromBytes, KnownLayout}; -use crate::file::array::{ArrayFile, ColumnScalar}; +use crate::file::{ + ArtifactFile, + array::{ArrayFile, ColumnScalar, InvalidColumnError, OpenArrayError}, +}; + +/// Opening a typed column over an array artifact failed. +#[derive(Debug)] +pub(crate) enum OpenColumnError { + /// The array file failed to open. + Open(OpenArrayError), + /// The file's element stamp is not the column's. + Invalid(InvalidColumnError), +} + +const impl From for OpenColumnError { + fn from(error: OpenArrayError) -> Self { + Self::Open(error) + } +} + +const impl From for OpenColumnError { + fn from(error: InvalidColumnError) -> Self { + Self::Invalid(error) + } +} + +impl fmt::Display for OpenColumnError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Open(error) => write!(fmt, "the array file failed to open: {error}"), + Self::Invalid(error) => { + write!( + fmt, + "the array's element stamp is not the column's: {error}" + ) + } + } + } +} + +impl Error for OpenColumnError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Open(error) => Some(error), + Self::Invalid(error) => Some(error), + } + } +} /// One array artifact proven to hold elements of type `T`, indexed by the id domain `I`. /// @@ -22,6 +70,22 @@ pub(crate) struct Column { domain: PhantomData T>, } +impl ArtifactFile for Column +where + I: Id, + T: ColumnScalar + FromBytes + KnownLayout, +{ + type Error = OpenColumnError; + + fn open(path: impl AsRef) -> Result + where + Self: Sized, + { + let file = ArrayFile::open(path)?; + Self::new(file).map_err(From::from) + } +} + impl Column where I: Id, @@ -29,24 +93,32 @@ where { /// Proves `file` holds elements of type `T`. /// - /// Returns [`None`] when the recorded element type or shape differs. - pub(crate) fn new(file: ArrayFile) -> Option { + /// # Errors + /// + /// Returns the [`InvalidColumnError`] when the file's element stamp is not `T`'s. + #[inline] + pub(crate) fn new(file: ArrayFile) -> Result { let _: &IdSlice = file.column()?; - Some(Self { + Ok(Self { file, domain: PhantomData, }) } /// Views the elements, indexed by the column's id domain. + #[inline] + #[must_use] pub(crate) fn view(&self) -> &IdSlice { - self.file - .column() - .expect("construction validated the element stamp") + // SAFETY: `Self::new` proved `file.column::()` succeeds for this file: its element + // stamp is `T`'s variant and trailing shape, and the open validated the data length. The + // file is immutable after open. Therefore the same view exists for the value's lifetime. + unsafe { self.file.column_unchecked() } } /// Counts the elements. + #[inline] + #[must_use] pub(crate) fn len(&self) -> usize { self.view().len() } diff --git a/libs/@local/graph/atlas/src/identity/node.rs b/libs/@local/graph/atlas/src/identity/node.rs index cdb35a1bee6..9436b48b60c 100644 --- a/libs/@local/graph/atlas/src/identity/node.rs +++ b/libs/@local/graph/atlas/src/identity/node.rs @@ -14,7 +14,7 @@ hashql_core::id::newtype! { serde::Serialize, serde::Deserialize, )] - #[id(endian = little, unaligned, const)] + #[id(endian = little, unaligned, const, derive(Step))] #[serde(into = "u64", try_from = "u64")] pub struct NodeRowId(u64) } diff --git a/libs/@local/graph/atlas/src/integrity/hash.rs b/libs/@local/graph/atlas/src/integrity/hash.rs index b7adbb3ac7e..87c316d3d4d 100644 --- a/libs/@local/graph/atlas/src/integrity/hash.rs +++ b/libs/@local/graph/atlas/src/integrity/hash.rs @@ -24,8 +24,6 @@ const DIGEST_BYTES: usize = ::Outp Debug, Copy, Clone, - PartialEq, - Eq, PartialOrd, Ord, serde::Serialize, @@ -41,39 +39,53 @@ const DIGEST_BYTES: usize = ::Outp #[serde(transparent)] #[schemars(transparent)] #[repr(transparent)] -pub struct Sha256Digest(HexBytes); +pub(crate) struct Sha256Digest(HexBytes); // byte arrays have identical representations on little- and big-endian targets. crate::dataset::offline::portable::self_archived!(Sha256Digest); impl Sha256Digest { - /// The digest width, bytes. - pub const BYTES: usize = DIGEST_BYTES; - /// Adopts `bytes` as a digest without computing anything. /// /// The caller asserts that `bytes` came out of a SHA-256 computation over the content this /// value names. This constructor cannot verify that. #[must_use] #[inline] - pub const fn from_bytes_unchecked(bytes: [u8; DIGEST_BYTES]) -> Self { + #[cfg(test)] // document codec tests need digests with chosen byte patterns. + pub(crate) const fn from_bytes_unchecked(bytes: [u8; DIGEST_BYTES]) -> Self { Self(HexBytes::new(bytes)) } /// Returns the raw SHA-256 bytes. #[must_use] #[inline] - pub const fn to_bytes(self) -> [u8; DIGEST_BYTES] { + pub(crate) const fn to_bytes(self) -> [u8; DIGEST_BYTES] { self.0.into_inner() } - pub fn of(value: impl AsRef<[u8]>) -> Self { + /// Returns the SHA-256 of `value`'s bytes, computed in one pass. + #[must_use] + pub(crate) fn of(value: impl AsRef<[u8]>) -> Self { let mut hasher = Sha256::new(); hasher.update(value.as_ref()); hasher.finalize() } } +const impl PartialEq for Sha256Digest { + /// Compares the two digests' bytes. + /// + /// The impl is manual and `const` so that a const context - a compile-time manifest check, + /// say - can compare digests. It is not a constant-time comparison, which a digest does not + /// need: it names public content rather than guarding a secret. + #[inline] + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +const impl Eq for Sha256Digest {} + impl fmt::Display for Sha256Digest { #[inline] fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { diff --git a/libs/@local/graph/atlas/src/integrity/hex.rs b/libs/@local/graph/atlas/src/integrity/hex.rs index 10b1b960c2e..b9b41403d12 100644 --- a/libs/@local/graph/atlas/src/integrity/hex.rs +++ b/libs/@local/graph/atlas/src/integrity/hex.rs @@ -5,7 +5,7 @@ use core::{array, error::Error, fmt, str::FromStr}; /// A string that is not canonical lowercase hexadecimal of the expected width. #[derive(Debug)] -pub enum ParseHexError { +pub(crate) enum ParseHexError { /// The input contains a number of characters other than the encoded width. Length { /// The number of characters the encoded value occupies. @@ -73,8 +73,6 @@ const fn nibble(byte: u8) -> (u8, u8) { #[derive( Copy, Clone, - PartialEq, - Eq, PartialOrd, Ord, zerocopy::ByteHash, @@ -85,7 +83,7 @@ const fn nibble(byte: u8) -> (u8, u8) { zerocopy::KnownLayout, )] #[repr(transparent)] -pub struct HexBytes([u8; N]); +pub(crate) struct HexBytes([u8; N]); impl HexBytes { /// Creates a value from its raw bytes. @@ -156,6 +154,15 @@ const impl AsMut<[u8]> for HexBytes { } } +const impl PartialEq for HexBytes { + #[inline] + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +const impl Eq for HexBytes {} + impl fmt::Debug for HexBytes { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { write!(fmt, "\"{self}\"") diff --git a/libs/@local/graph/atlas/src/integrity/secret.rs b/libs/@local/graph/atlas/src/integrity/secret.rs index 379d444f1b7..8f4b69e65c5 100644 --- a/libs/@local/graph/atlas/src/integrity/secret.rs +++ b/libs/@local/graph/atlas/src/integrity/secret.rs @@ -252,12 +252,10 @@ impl FromStr for PasswordString { pub(crate) struct SecretHexBytes(HexBytes); impl SecretHexBytes { - /// Wraps raw secret bytes. - #[cfg(test)] // required by `WireSecret` - pub(crate) const fn new(bytes: [u8; N]) -> Self { - Self(HexBytes::new(bytes)) - } - + /// Returns `N` zero bytes: the buffer a key derivation fills through [`AsMut`]. + /// + /// The value is a placeholder awaiting the derivation's output. Reading it before something + /// has written the derived bytes over it reads zeros. pub(crate) const fn zeroed() -> Self { Self(HexBytes::new([0_u8; N])) } diff --git a/libs/@local/graph/atlas/src/lib.rs b/libs/@local/graph/atlas/src/lib.rs index ec4b49a5daf..8e5ea01d567 100644 --- a/libs/@local/graph/atlas/src/lib.rs +++ b/libs/@local/graph/atlas/src/lib.rs @@ -110,6 +110,7 @@ const_index, const_ops, const_option_ops, + const_result_trait_fn, const_try, exact_size_is_empty, file_buffered, @@ -121,14 +122,17 @@ iterator_try_collect, nonpoison_mutex, nonpoison_rwlock, + option_into_flat_iter, pointer_is_aligned_to, portable_simd, ptr_metadata, + slice_shift, step_trait, sync_nonpoison, time_saturating_systemtime, - variant_count, + unboxed_closures, unwrap_infallible, + variant_count, )] #![cfg_attr(feature = "cli", feature(exitcode_exit_method))] #![cfg_attr(test, feature(iter_intersperse))] diff --git a/libs/@local/graph/atlas/src/math/affinity/fit.rs b/libs/@local/graph/atlas/src/math/affinity/fit.rs index 0ba2b99664c..19048085e4a 100644 --- a/libs/@local/graph/atlas/src/math/affinity/fit.rs +++ b/libs/@local/graph/atlas/src/math/affinity/fit.rs @@ -230,7 +230,7 @@ impl NormalEquationsDerivation { }; /// Validates the accumulated sums, returning [`None`] if any is non-finite. - fn finish(self) -> Option { + const fn finish(self) -> Option { Some(NormalEquations { residual_sum_of_squares: self.residual_sum_of_squares.finish().ok()?, j_aa: self.j_aa.finish().ok()?, diff --git a/libs/@local/graph/atlas/src/morton/mod.rs b/libs/@local/graph/atlas/src/morton/mod.rs index ff58c65489b..648d99d7462 100644 --- a/libs/@local/graph/atlas/src/morton/mod.rs +++ b/libs/@local/graph/atlas/src/morton/mod.rs @@ -23,39 +23,37 @@ ) )] -#[cfg(test)] -mod tests; +use core::{fmt, iter::Step}; -/// A subdivision depth between the whole domain and a single key. -/// -/// Depth `d` cells are the squares of a `2^d x 2^d` grid over the axis domain. [`Depth::MIN`] is -/// the whole domain; [`Depth::MAX`] fixes all 32 bits of both axes, so a cell at it holds exactly -/// one key. -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Depth(u8); +use hashql_core::id::Id as _; -impl Depth { - /// Both axes fully specified: one key per cell. - pub const MAX: Self = Self(32); - /// One cell covering the whole domain. - pub const MIN: Self = Self(0); +use crate::math::{Log2, unsafe_impl_try_from_bytes}; - /// Wraps a subdivision count. +#[cfg(test)] +mod tests; + +hashql_core::id::newtype! { + /// A subdivision depth between the whole domain and a single key. /// - /// Depth d cells are the squares of a 2ᵈ × 2ᵈ grid over the axis domain. [`Depth::MIN`] is the - /// whole domain. [`Depth::MAX`] fixes all 32 bits of both axes and identifies one key. + /// Depth d cells are the squares of a 2ᵈ × 2ᵈ grid over the axis domain. [`Depth::MIN`] is the whole domain. [`Depth::MAX`] fixes all 32 bits of both axes and identifies one key. /// - /// # Examples + /// Serializes as a byte-valued integer. Deserialization rejects values above 32. + #[id(unaligned, const, derive(Step))] + pub struct Depth(u8 is 0..=32) +} + +impl Depth { + /// Validates a subdivision count. /// /// Returns [`None`] above [`Depth::MAX`], the key width of one axis. #[inline] #[must_use] - pub const fn new(depth: u8) -> Option { - if depth > Self::MAX.0 { + pub const fn try_new(depth: u8) -> Option { + if depth > Self::MAX.get() { return None; } - Some(Self(depth)) + Some(Self::new(depth)) } /// Returns the depth whose grid a tile zoom addresses. @@ -63,8 +61,37 @@ impl Depth { /// Zoom z and depth z name the same 2ᶻ × 2ᶻ grid, and both types end at [`Depth::MAX`]. #[inline] #[must_use] - pub const fn get(self) -> u8 { - self.0 + pub const fn from_zoom(zoom: Zoom) -> Self { + Self::new(zoom.get()) + } + + /// Returns the subdivisions left below this depth, `32 - depth`, as a zoom offset. + /// + /// A schedule that cuts at depth `s + k` for an offset `k` can raise `k` this far before the + /// cut leaves the key width. + #[inline] + #[must_use] + pub const fn ceiling(self) -> Zoom { + Zoom(Self::MAX.get() - self.get()) + } + + /// Returns the first zoom whose cut reaches this bucket depth. + /// + /// For a cut at z + s, where z is the zoom and s is `span`, a bucket at depth b first satisfies + /// b ≤ z + s at z = max(b − s, 0). + #[inline] + #[must_use] + pub const fn first_zoom(self, span: Log2) -> Zoom { + Zoom(self.get().saturating_sub(span.get())) + } + + /// Converts a cut depth to a zoom, clamping depths below `span` to the root. + /// + /// Returns max(depth − span, 0), as [`Self::first_zoom`] does. + #[inline] + #[must_use] + pub const fn zoom(self, span: Log2) -> Zoom { + Zoom(self.get().saturating_sub(span.get())) } /// Adds `steps` subdivisions, saturating at [`Depth::MAX`]. @@ -87,19 +114,282 @@ impl Depth { /// ``` #[inline] #[must_use] - pub const fn saturating_add(self, steps: u8) -> Self { - let sum = self.0.saturating_add(steps); - if sum > Self::MAX.0 { - Self::MAX - } else { - Self(sum) - } + pub const fn saturating_add(self, steps: Log2) -> Self { + let sum = self.get().saturating_add(steps.get()); + Self::try_new(sum).unwrap_or(Self::MAX) + } + + /// Adds `steps` subdivisions, [`None`] past [`Depth::MAX`]. + #[inline] + #[must_use] + pub const fn checked_add(self, steps: Log2) -> Option { + let sum = self.get().checked_add(steps.get())?; + Self::try_new(sum) } /// Iterates every depth, [`Depth::MIN`] through [`Depth::MAX`]. #[inline] + #[must_use] pub fn all() -> impl DoubleEndedIterator { - (Self::MIN.0..=Self::MAX.0).map(Self) + Self::MIN..=Self::MAX + } +} + +impl serde::Serialize for Depth { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u8(self.get()) + } +} + +impl<'de> serde::Deserialize<'de> for Depth { + fn deserialize>(deserializer: D) -> Result { + let value = u8::deserialize(deserializer)?; + Self::try_new(value).ok_or_else(|| { + serde::de::Error::invalid_value( + serde::de::Unexpected::Unsigned(u64::from(value)), + &"a subdivision depth within the key width", + ) + }) + } +} + +impl schemars::JsonSchema for Depth { + fn schema_name() -> alloc::borrow::Cow<'static, str> { + "Depth".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "integer", + "minimum": Self::MIN.get(), + "maximum": Self::MAX.get(), + }) + } +} + +/// A tile zoom level within the key width. +/// +/// Zoom z addresses the tiles of the 2ᶻ × 2ᶻ grid, the cells of [`Depth`] z. Distinct types keep a +/// tile's level and a key's subdivision depth out of each other's arithmetic. +/// +/// Serializes as a byte-valued integer. Deserialization rejects values above 32. +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + zerocopy::IntoBytes, + zerocopy::Immutable, + zerocopy::Unaligned, + zerocopy::KnownLayout, +)] +#[repr(transparent)] +pub struct Zoom(u8); + +impl Zoom { + /// The maximum zoom level, [`Depth::MAX`]. + pub(crate) const MAX: Self = Self(Depth::MAX.get()); + /// The minimum zoom level, [`Depth::MIN`]. + pub(crate) const MIN: Self = Self(0); + + /// Validates a tile zoom level. + /// + /// Returns [`None`] above [`Depth::MAX`], the deepest grid a key addresses. + pub(crate) const fn new(zoom: u8) -> Option { + if zoom > Depth::MAX.get() { + return None; + } + + Some(Self(zoom)) + } + + /// Accepts exactly the byte patterns stored by [`new`](Self::new). + const fn is_canonical(value: u8) -> bool { + match Self::new(value) { + Some(accepted) => accepted.0 == value, + None => false, + } + } + + /// Returns the cut depth `zoom + span`, [`None`] past [`Depth::MAX`]. + pub(crate) const fn depth(self, span: Log2) -> Option { + Depth::new(self.get()).checked_add(span) + } + + /// Returns the cut depth `zoom + span`, saturating at [`Depth::MAX`]. + pub(crate) const fn saturating_depth(self, span: Log2) -> Depth { + Depth::new(self.get()).saturating_add(span) + } + + /// Returns the zoom one level shallower, [`None`] at the root. + #[must_use] + pub(crate) const fn shallower(self) -> Option { + match self.0.checked_sub(1) { + Some(level) => Some(Self(level)), + None => None, + } + } + + /// Returns the zoom one level deeper, [`None`] at [`Zoom::MAX`]. + #[must_use] + pub(crate) const fn deeper(self) -> Option { + // Incrementing a level at or below `Depth::MAX` fits within `u8`. + Self::new(self.0 + 1) + } + + /// Returns the level. + pub(crate) const fn get(self) -> u8 { + self.0 + } +} + +unsafe_impl_try_from_bytes!(Zoom[u8]); + +impl From for Log2 { + fn from(zoom: Zoom) -> Self { + // 32 < u64::BITS + Self::new_unchecked(zoom.get()) + } +} + +impl fmt::Display for Zoom { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, fmt) + } +} + +impl Step for Zoom { + fn steps_between(start: &Self, end: &Self) -> (usize, Option) { + u8::steps_between(&start.0, &end.0) + } + + fn forward_checked(start: Self, count: usize) -> Option { + u8::forward_checked(start.0, count).and_then(Self::new) + } + + #[expect( + clippy::integer_division_remainder_used, + reason = "the wrap folds the step count onto the `MAX + 1` cycle" + )] + fn forward_overflowing(start: Self, count: usize) -> (Self, bool) { + Self::forward_checked(start, count).map_or_else( + || { + // Stepping past `MAX` wraps into the domain as if the levels + // formed a cycle of `MAX + 1` values, mirroring the primitive + // integers' overflow semantics on this bounded range. + let span = usize::from(Self::MAX.0) + 1; + let wrapped = (usize::from(start.0) + count % span) % span; + #[expect( + clippy::cast_possible_truncation, + reason = "the wrapped value is below `span`, which fits u8" + )] + (Self(wrapped as u8), true) + }, + |zoom| (zoom, false), + ) + } + + fn backward_checked(start: Self, count: usize) -> Option { + // Any value below `start` is a valid zoom. Only `u8` underflow can fail. + u8::backward_checked(start.0, count).map(Self) + } + + #[expect( + clippy::integer_division_remainder_used, + reason = "the wrap folds the step count onto the `MAX + 1` cycle" + )] + fn backward_overflowing(start: Self, count: usize) -> (Self, bool) { + Self::backward_checked(start, count).map_or_else( + || { + // Stepping below `MIN` wraps around the same `MAX + 1` cycle + // as the forward direction. + let span = usize::from(Self::MAX.0) + 1; + let wrapped = (usize::from(start.0) + span - count % span) % span; + #[expect( + clippy::cast_possible_truncation, + reason = "the wrapped value is below `span`, which fits u8" + )] + (Self(wrapped as u8), true) + }, + |zoom| (zoom, false), + ) + } +} + +impl serde::Serialize for Zoom { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u8(self.0) + } +} + +impl<'de> serde::Deserialize<'de> for Zoom { + fn deserialize>(deserializer: D) -> Result { + let value = u8::deserialize(deserializer)?; + Self::new(value).ok_or_else(|| { + serde::de::Error::invalid_value( + serde::de::Unexpected::Unsigned(u64::from(value)), + &"a zoom level within the key width", + ) + }) + } +} + +impl schemars::JsonSchema for Zoom { + fn schema_name() -> alloc::borrow::Cow<'static, str> { + "Zoom".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "integer", + "minimum": Self::MIN.get(), + "maximum": Self::MAX.get(), + }) + } +} + +/// A depth and cell coordinates on that depth's grid. +/// +/// The fields alone do not enforce coordinate bounds. [`MortonCell::from_tile`] validates that x +/// and y each lie below 2ᶻ. +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Deserialize, schemars::JsonSchema)] +pub(crate) struct MortonTile { + /// The zoom, a subdivision depth. + pub z: Depth, + /// The cell's x index on the 2ᶻ × 2ᶻ grid. + pub x: u32, + /// The cell's y index on the 2ᶻ × 2ᶻ grid. + pub y: u32, +} + +impl MortonTile { + /// Returns the tile's ancestor on `shallower`'s grid. + /// + /// # Panics + /// + /// This panics when `shallower` lies deeper than the tile's own zoom. + #[expect( + clippy::cast_possible_truncation, + reason = "an ancestor coordinate is at most the original, which fits u32" + )] + #[must_use] + pub(crate) const fn ancestor(self, shallower: Depth) -> Self { + assert!( + shallower.get() <= self.z.get(), + "the ancestor's grid lies at or above the tile's" + ); + + // The widened shift keeps the deepest zoom's root ancestor in range: the level + // difference can reach the full 32-bit axis width. + let shift = (self.z.get() - shallower.get()) as u32; + Self { + z: shallower, + x: ((self.x as u64) >> shift) as u32, + y: ((self.y as u64) >> shift) as u32, + } } } @@ -153,6 +443,21 @@ impl MortonKey { [compact_bits(self.0), compact_bits(self.0 >> 1)] } + /// Returns the tile address of the cell containing this key at `depth`. + /// + /// The address is the key's coordinates truncated to their leading `depth` bits. + #[inline] + #[must_use] + pub(crate) const fn tile(self, depth: Depth) -> MortonTile { + let [x, y] = self.coordinates(); + MortonTile { + z: Depth::MAX, + x, + y, + } + .ancestor(depth) + } + /// Returns the cell index at `depth`. /// /// The leading 2d key bits, where d is `depth`. These values densely index the cells from zero @@ -196,7 +501,7 @@ impl MortonKey { #[expect( clippy::integer_division, clippy::integer_division_remainder_used, - reason = "a cell index is two key bits, so the shared depth is the agreed bit count halved" + reason = "the shared depth counts agreed two-bit pairs" )] #[inline] #[must_use] @@ -207,8 +512,8 @@ impl MortonKey { )] let agreed = ((self.0 ^ other.0).leading_zeros() / 2) as u8; - // At most 64 agreed bits halve to 32, which is `Depth::MAX` itself. - Depth::new(agreed).unwrap_or_else(const || unreachable!()) + // Half of at most 64 agreed bits fits within `Depth::MAX`. + Depth::new(agreed) } /// Returns the cell containing this key at `depth`. @@ -272,6 +577,15 @@ impl MortonCell { }) } + /// Validates the coordinates of a tile address and selects its cell. + /// + /// Returns [`None`] when the address lies outside its zoom's grid, as [`Self::new`] does. + #[inline] + #[must_use] + pub(crate) const fn from_tile(tile: MortonTile) -> Option { + Self::new(tile.z, tile.x, tile.y) + } + /// Returns the cell's depth. #[inline] #[must_use] @@ -306,7 +620,7 @@ impl MortonCell { /// children's ranges partition the parent's in that order. Returns [`None`] at [`Depth::MAX`]. #[must_use] pub const fn children(self) -> Option<[Self; 4]> { - let depth = Depth::new(self.depth.get() + 1)?; + let depth = Depth::try_new(self.depth.get() + 1)?; let step = 1_u64 << (64 - 2 * (depth.get() as u32)); Some([ diff --git a/libs/@local/graph/atlas/src/morton/tests.rs b/libs/@local/graph/atlas/src/morton/tests.rs index 3756a26b1eb..8c1654b24dc 100644 --- a/libs/@local/graph/atlas/src/morton/tests.rs +++ b/libs/@local/graph/atlas/src/morton/tests.rs @@ -1,10 +1,32 @@ +use hashql_core::id::Id as _; use proptest::{prop_assert, prop_assert_eq, prop_assert_ne, property_test}; +use zerocopy::TryFromBytes as _; -use super::{Depth, MortonCell, MortonKey}; +use super::{Depth, MortonCell, MortonKey, MortonTile, Zoom}; /// Borrowed and copied decoding admit exactly the constructor's zoom domain. #[test] -fn curve_start_matches_the_hand_table() { +fn zoom_byte_domain() { + for byte in u8::MIN..=u8::MAX { + let bytes = [byte]; + let expected = Zoom::new(byte); + assert_eq!( + Zoom::try_read_from_bytes(&bytes).ok(), + expected, + "copied decoding should enforce the zoom domain for byte {byte}" + ); + assert_eq!( + Zoom::try_ref_from_bytes(&bytes).ok().copied(), + expected, + "borrowed decoding should enforce the zoom domain for byte {byte}" + ); + } +} + +/// The first sixteen keys trace the Z-order curve over the 4 x 4 grid, with `x` on the even bits +/// and `y` on the odd bits, in both encoding directions. +#[test] +fn curve_start() { // The Z-order curve over the 4 x 4 grid, keys 0..16 by hand: // x supplies the even bits, y the odd bits. let expected = [ @@ -35,7 +57,7 @@ fn curve_start_matches_the_hand_table() { /// Saturated axes interleave to the all-ones key, and a single saturated axis to its alternating /// bit mask. #[test] -fn extremes_interleave_exactly() { +fn interleave_extremes() { assert_eq!(MortonKey::new(0, 0).to_bits(), 0); assert_eq!(MortonKey::new(u32::MAX, u32::MAX).to_bits(), u64::MAX); assert_eq!(MortonKey::new(u32::MAX, 0).to_bits(), 0x5555_5555_5555_5555); @@ -44,14 +66,14 @@ fn extremes_interleave_exactly() { /// `Depth::try_new` admits exactly `0..=32`, mapping the ends to `MIN` and `MAX`. #[test] -fn depth_admits_the_documented_domain() { - assert_eq!(Depth::new(0), Some(Depth::MIN)); - assert_eq!(Depth::new(32), Some(Depth::MAX)); - assert_eq!(Depth::new(33), None); +fn depth_domain() { + assert_eq!(Depth::try_new(0), Some(Depth::MIN)); + assert_eq!(Depth::try_new(32), Some(Depth::MAX)); + assert_eq!(Depth::try_new(33), None); } #[test] -fn root_cell_spans_every_key() { +fn cell_root() { let root = MortonCell::new(Depth::MIN, 0, 0).expect("the origin lies in the one root cell"); assert_eq!(root.min_key(), MortonKey::from_bits(0)); @@ -61,7 +83,7 @@ fn root_cell_spans_every_key() { } #[test] -fn full_depth_cell_is_one_key() { +fn cell_full_depth() { let key = MortonKey::new(7, 11); let cell = key.cell(Depth::MAX); @@ -78,8 +100,8 @@ fn full_depth_cell_is_one_key() { /// `MortonCell::new` admits coordinates below `2^depth` on each axis and refuses the first /// coordinate at or beyond it. #[test] -fn cell_addresses_reject_coordinates_outside_the_grid() { - let depth = Depth::new(3).expect("3 subdivisions lie below the maximum of 32"); +fn cell_coordinates_outside_grid() { + let depth = Depth::try_new(3).expect("3 subdivisions lie below the maximum of 32"); assert!(MortonCell::new(depth, 7, 7).is_some()); assert_eq!(MortonCell::new(depth, 8, 0), None); @@ -89,19 +111,19 @@ fn cell_addresses_reject_coordinates_outside_the_grid() { /// A key's prefix at depth `d` is its leading `2d` interleaved bits: empty at depth zero and the /// whole key at maximum depth. #[test] -fn prefixes_index_the_depth_grid_in_key_order() { +fn prefix_grid_order() { // Cell (x = 2, y = 3) of the depth-2 grid: axis bits sit at the // top of each 32-bit axis, and the prefix interleaves them. let key = MortonKey::new(2 << 30, 3 << 30); - let depth = Depth::new(2).expect("2 subdivisions lie below the maximum of 32"); + let depth = Depth::try_new(2).expect("2 subdivisions lie below the maximum of 32"); assert_eq!(key.prefix(depth), 0b1110); assert_eq!(key.prefix(Depth::MIN), 0); assert_eq!(key.prefix(Depth::MAX), key.to_bits()); } #[test] -fn shared_depth_counts_the_agreed_bit_pairs() { +fn shared_depth_bit_pairs() { let key = MortonKey::new(0, 0); assert_eq!(key.shared_depth(key), Depth::MAX); @@ -111,21 +133,54 @@ fn shared_depth_counts_the_agreed_bit_pairs() { assert_eq!(key.shared_depth(MortonKey::new(1 << 30, 0)).get(), 1); } +/// A key's tile at depth `d` carries the leading `d` bits of each axis, from the root tile at +/// depth zero to the full coordinates at maximum depth. +#[test] +fn tile_depth_boundaries() { + let key = MortonKey::new(0xC123_4567, 0x5123_ABCD); + + for (depth, x, y) in [ + (Depth::MIN, 0, 0), + (Depth::new(2), 3, 1), + (Depth::MAX, 0xC123_4567, 0x5123_ABCD), + ] { + assert_eq!( + key.tile(depth), + MortonTile { z: depth, x, y }, + "the tile should contain the leading axis bits at depth {depth:?}" + ); + } +} + +/// A key's tile at any depth addresses the same cell the key's `cell` names at that depth. +#[property_test] +fn tile_cells(bits: u64) { + let key = MortonKey::from_bits(bits); + + for depth in 0..=Depth::MAX.get() { + let depth = Depth::new(depth); + prop_assert_eq!( + MortonCell::from_tile(key.tile(depth)), + Some(key.cell(depth)) + ); + } +} + /// Interleaving then deinterleaving returns the axes exactly. #[property_test] -fn round_trips_axes(x: u32, y: u32) { +fn axes_round_trip(x: u32, y: u32) { prop_assert_eq!(MortonKey::new(x, y).coordinates(), [x, y]); } /// The shared depth names the deepest grid whose cells hold both keys. #[property_test] -fn shared_depth_is_the_deepest_shared_cell(left: u64, right: u64) { +fn shared_depth_maximal(left: u64, right: u64) { let left = MortonKey::from_bits(left); let right = MortonKey::from_bits(right); let shared = left.shared_depth(right); prop_assert_eq!(left.prefix(shared), right.prefix(shared)); - if let Some(finer) = Depth::new(shared.get() + 1) { + if let Some(finer) = Depth::try_new(shared.get() + 1) { prop_assert_ne!(left.prefix(finer), right.prefix(finer)); } else { prop_assert_eq!(left, right); @@ -136,8 +191,8 @@ fn shared_depth_is_the_deepest_shared_cell(left: u64, right: u64) { /// /// The cell's address form agrees with the key's prefix. #[property_test] -fn keys_lie_in_their_cells(bits: u64, #[strategy = 0_u8..=32] depth: u8) { - let depth = Depth::new(depth).expect("the strategy stays within the documented domain"); +fn cell_containment(bits: u64, #[strategy = 0_u8..=32] depth: u8) { + let depth = Depth::try_new(depth).expect("the strategy stays within the documented domain"); let key = MortonKey::from_bits(bits); let cell = key.cell(depth); @@ -149,8 +204,8 @@ fn keys_lie_in_their_cells(bits: u64, #[strategy = 0_u8..=32] depth: u8) { /// Children partition the parent range contiguously, in key order. #[property_test] -fn children_partition_the_parent(bits: u64, #[strategy = 0_u8..32] depth: u8) { - let depth = Depth::new(depth).expect("the strategy stays within the documented domain"); +fn children_partition(bits: u64, #[strategy = 0_u8..32] depth: u8) { + let depth = Depth::try_new(depth).expect("the strategy stays within the documented domain"); let parent = MortonKey::from_bits(bits).cell(depth); let children = parent .children() @@ -169,15 +224,15 @@ fn children_partition_the_parent(bits: u64, #[strategy = 0_u8..32] depth: u8) { /// A key's next axis bits select the child that contains it. #[property_test] -fn child_indexes_follow_the_axis_bits(bits: u64, #[strategy = 0_u8..32] depth: u8) { - let depth = Depth::new(depth).expect("the strategy stays within the documented domain"); +fn child_axis_bits(bits: u64, #[strategy = 0_u8..32] depth: u8) { + let depth = Depth::try_new(depth).expect("the strategy stays within the documented domain"); let key = MortonKey::from_bits(bits); let children = key .cell(depth) .children() .expect("depths below the maximum subdivide"); - let child_depth = Depth::new(depth.get() + 1) + let child_depth = Depth::try_new(depth.get() + 1) .expect("one more subdivision stays within the documented domain"); let [x, y] = key.coordinates(); let axis_bit = |axis: u32| (axis >> (32 - u32::from(child_depth.get()))) & 1; diff --git a/libs/@local/graph/atlas/src/offload.rs b/libs/@local/graph/atlas/src/offload.rs index 7eaeb2629cc..4edf82a213b 100644 --- a/libs/@local/graph/atlas/src/offload.rs +++ b/libs/@local/graph/atlas/src/offload.rs @@ -5,12 +5,11 @@ //! completion without waiting. use alloc::borrow::Cow; -use core::{any::Any, error::Error, fmt, panic::UnwindSafe}; +use core::{any::Any, error::Error, fmt, panic::UnwindSafe, pin, task, task::ready}; -/// An offloaded computation that produced no value. -/// -/// A route maps the failure to an internal problem, and a resolution maps it to its resolver's -/// error. +use futures::FutureExt as _; + +/// A failure to collect an offloaded computation's result. #[derive(Debug)] pub(crate) enum OffloadError { /// The computation panicked, with a message for string panic payloads. @@ -24,29 +23,108 @@ impl fmt::Display for OffloadError { match self { Self::Panicked(Some(payload)) => write!(fmt, "the offloaded work panicked: {payload}"), Self::Panicked(None) => fmt.write_str("the offloaded work panicked"), - Self::Vanished => fmt.write_str("the offload worker vanished without answering"), + Self::Vanished => fmt.write_str("the offload handle has no result to return"), } } } impl Error for OffloadError {} -/// Runs `work` on a rayon worker and returns its value, answering a panic as an error. +/// The result of checking an offloaded computation without waiting. +pub(crate) enum OffloadState { + /// The computation's return value. + Finished(T), + /// No result is available yet. + Running, +} + +/// An awaitable result from a computation submitted to Rayon. +/// +/// Awaiting the handle returns the value or an [`OffloadError`]. Use [`try_join`](Self::try_join) +/// when you need to check for a result without waiting. The handle returns its result only once. +/// +/// # Cancellation +/// +/// Dropping the handle does not cancel queued or running work. The computation continues, and the +/// worker drops its result if delivery fails. +/// +/// # Completion +/// +/// An [`OffloadState::Running`] result permits another check or await. Every other `try_join` +/// result completes the handle, as does a ready [`Future::poll`]. Further `try_join` calls return +/// [`OffloadError::Vanished`]. +/// +/// # Panics +/// +/// Polling through [`Future`] after completion panics, including after a terminal `try_join` call. +/// Collecting a panic also panics if its non-string payload has a panicking destructor. +pub(crate) struct OffloadHandle { + receiver: tokio::sync::oneshot::Receiver>>, +} + +impl OffloadHandle { + /// Takes the computation's result if it is available. + /// + /// This never waits. [`OffloadState::Running`] leaves the handle available for another check or + /// await. The check does not register a wakeup when the result becomes available. + /// + /// # Errors + /// + /// Returns [`OffloadError`] if the computation panicked, the worker closed the channel without + /// a result, or the handle has already completed. + /// + /// # Panics + /// + /// Panics if dropping a non-string panic payload panics. + pub(crate) fn try_join(&mut self) -> Result, OffloadError> { + match self.receiver.try_recv() { + Ok(Ok(value)) => Ok(OffloadState::Finished(value)), + Ok(Err(panic)) => Err(OffloadError::Panicked(panic_message(panic))), + Err(tokio::sync::oneshot::error::TryRecvError::Closed) => Err(OffloadError::Vanished), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => Ok(OffloadState::Running), + } + } +} + +impl Future for OffloadHandle { + type Output = Result; + + fn poll(mut self: pin::Pin<&mut Self>, cx: &mut task::Context<'_>) -> task::Poll { + let value = ready!(self.receiver.poll_unpin(cx)); + + let value = match value { + Ok(Ok(value)) => Ok(value), + Ok(Err(panic)) => Err(OffloadError::Panicked(panic_message(panic))), + Err(_closed) => Err(OffloadError::Vanished), + }; + + task::Poll::Ready(value) + } +} + +/// Submits `work` to Rayon without waiting for it to finish. /// /// Submission starts the job independently of polling the returned handle. The worker enters the /// current tracing span for both the computation and cleanup of an undeliverable result. /// /// # Errors /// -/// Returns [`OffloadError::Panicked`] when the work panics, with the payload's text when the -/// payload was one, and [`OffloadError::Vanished`] when the pool drops the job without running -/// it. -pub(crate) async fn run( +/// The returned [`OffloadHandle`] reports [`OffloadError`] when it cannot collect a result. +/// +/// # Panic handling +/// +/// An unwinding panic from `work` becomes [`OffloadError::Panicked`]. Cleanup of an undeliverable +/// result has a separate [`catch_unwind`](std::panic::catch_unwind), whose panic has no recipient. +/// Both catches handle unwinding only: an abort terminates the process, and dropping a caught panic +/// payload can itself panic. +pub(crate) fn run( work: impl FnOnce() -> T + Send + UnwindSafe + 'static, -) -> Result { +) -> OffloadHandle { let (sender, receiver) = tokio::sync::oneshot::channel(); + let span = tracing::Span::current(); rayon::spawn(move || { + let _entered = span.enter(); let result = std::panic::catch_unwind(work); // A rejected result can panic during drop after the computation's unwind boundary has @@ -58,11 +136,7 @@ pub(crate) async fn run( })); }); - match receiver.await { - Ok(Ok(value)) => Ok(value), - Ok(Err(panic)) => Err(OffloadError::Panicked(panic_message(panic))), - Err(_closed) => Err(OffloadError::Vanished), - } + OffloadHandle { receiver } } /// Extracts a string panic message and discards other payloads. @@ -83,22 +157,145 @@ fn panic_message(panic: Box) -> Option> { #[cfg(test)] pub(crate) mod tests { - use super::{OffloadError, run}; + use core::{any::Any, slice, time::Duration}; + use std::sync::mpsc; + + use tokio::sync::oneshot; + use tracing::{Dispatch, Event, Subscriber, span::Id}; + use tracing_subscriber::{ + Layer, Registry, + layer::{Context, SubscriberExt as _}, + registry::LookupSpan, + }; + + use super::{OffloadError, OffloadHandle, run}; + + /// A subscriber layer that reports each event's span scope, root first. + struct Scopes(mpsc::Sender>); + + impl LookupSpan<'lookup>> Layer for Scopes { + fn on_event(&self, event: &Event<'_>, context: Context<'_, S>) { + let scope = context.event_scope(event).map_or_else(Vec::new, |scope| { + scope.from_root().map(|span| span.id()).collect() + }); + self.0 + .send(scope) + .expect("should retain the event receiver"); + } + } + + /// A value whose destructor emits a tracing event. + struct DropEvent; + + impl Drop for DropEvent { + fn drop(&mut self) { + tracing::info!("drop the cancelled result"); + } + } + + /// Work and rejected-result cleanup retain the scheduling span until the worker returns. + #[test] + fn work_tracing_context() { + let (events, received) = mpsc::channel(); + let dispatch = Dispatch::new(Registry::default().with(Scopes(events))); + let worker_dispatch = dispatch.clone(); + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .spawn_handler(move |thread| { + let dispatch = worker_dispatch.clone(); + std::thread::spawn(move || { + tracing::dispatcher::with_default(&dispatch, || thread.run()); + }); + Ok(()) + }) + .build() + .expect("should build the worker with the test subscriber"); + + let next_scope = || { + received + .recv_timeout(Duration::from_secs(10)) + .expect("should record the worker event") + }; + tracing::dispatcher::with_default(&dispatch, || { + let requests = [ + tracing::info_span!("request"), + tracing::info_span!("request"), + ]; + for request in &requests { + let id = request.id().expect("should enable the request span"); + let handle = pool.install(|| { + request.in_scope(|| { + run(|| { + tracing::info!("complete the work"); + 42 + }) + }) + }); + assert_eq!( + futures::executor::block_on(handle).expect("should complete"), + 42 + ); + assert_eq!(next_scope(), [id]); + } + + let request = tracing::info_span!("request"); + let id = request.id().expect("should enable the request span"); + let (release, held) = mpsc::channel(); + let handle = pool.install(|| { + request.in_scope(|| { + run(move || { + held.recv().expect("should release the cancelled work"); + DropEvent + }) + }) + }); + drop(handle); + release.send(()).expect("should retain the worker receiver"); + assert_eq!(next_scope().as_slice(), slice::from_ref(&id)); + + let handle = pool.install(|| { + request.in_scope(|| { + run(|| { + tracing::info!("panic during work"); + panic!("the fixture panicked on purpose"); + }) + }) + }); + core::assert_matches!( + futures::executor::block_on(handle), + Err(OffloadError::Panicked(Some(_))) + ); + assert_eq!(next_scope(), [id]); + + pool.install(|| tracing::info!("run unrelated work")); + assert_eq!(next_scope(), []); + let handle = pool.install(|| run(|| tracing::info!("run work without a span"))); + futures::executor::block_on(handle).expect("should complete work without a span"); + assert_eq!(next_scope(), []); + }); + } + + /// Creates a handle whose result comes from the supplied receiver. + pub(crate) fn from_receiver( + receiver: oneshot::Receiver>>, + ) -> OffloadHandle { + OffloadHandle { receiver } + } #[tokio::test] - async fn completed_work_answers_its_value() { + async fn work_completed() { let value = run(|| 6 * 7).await.expect("the work completes"); assert_eq!(value, 42); } #[tokio::test] - async fn panicking_work_answers_an_error_without_aborting() { + async fn work_panic() { let error = run(|| -> u32 { panic!("the fixture panicked on purpose") }) .await .expect_err("the panic answers as an error"); let OffloadError::Panicked(Some(payload)) = error else { - panic!("the worker ran the closure, so the failure carries the panic's text"); + panic!("should receive a text panic payload"); }; assert_eq!(payload, "the fixture panicked on purpose"); @@ -107,33 +304,34 @@ pub(crate) mod tests { } #[tokio::test] - async fn formatted_panic_payload_keeps_its_text() { + async fn panic_formatted() { let error = run(|| -> u32 { panic!("row {} is out of range", 41) }) .await .expect_err("the panic answers as an error"); let OffloadError::Panicked(Some(payload)) = error else { - panic!("the worker ran the closure, so the failure carries the panic's text"); + panic!("should receive a text panic payload"); }; assert_eq!(payload, "row 41 is out of range"); } #[tokio::test] - async fn textless_panic_payload_answers_none() { + async fn panic_nontext() { let error = run(|| -> u32 { std::panic::panic_any(41_u64) }) .await .expect_err("the panic answers as an error"); - assert!( - matches!(error, OffloadError::Panicked(None)), + core::assert_matches!( + error, + OffloadError::Panicked(None), "a numeric payload has no text to extract" ); } /// The worker catches a string panic from a rejected value's destructor. #[tokio::test] - async fn cancelled_send_with_panicking_destructor_does_not_abort() { - /// Signals that its drop ran, then panics inside it. + async fn cancelled_send_panicking_destructor() { + /// A value whose destructor reports on its channel and then panics. struct PanicsOnDrop(std::sync::mpsc::Sender<()>); impl Drop for PanicsOnDrop { @@ -143,25 +341,23 @@ pub(crate) mod tests { } } + // a single worker orders the follow-up job after the destructor's unwind. + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .build() + .expect("a single-worker pool builds"); + let (release, held) = std::sync::mpsc::channel::<()>(); let (dropped, drop_witness) = std::sync::mpsc::channel::<()>(); - // Poll the offload once so the worker spawns, then drop it on the timeout: the receiver - // is gone before the worker answers, because the worker waits on `held` until the - // release below. - let cancelled = tokio::time::timeout( - core::time::Duration::from_millis(10), + let cancelled = pool.install(|| { run(move || { held.recv() .expect("the test releases the worker after cancelling"); PanicsOnDrop(dropped) - }), - ) - .await; - assert!( - cancelled.is_err(), - "the held worker cannot answer before the timeout" - ); + }) + }); + drop(cancelled); release.send(()).expect("the worker waits on this release"); @@ -169,7 +365,8 @@ pub(crate) mod tests { .recv_timeout(core::time::Duration::from_secs(10)) .expect("the rejected value's destructor runs on the worker"); - let value = run(|| 7) + let value = pool + .install(|| run(|| 7)) .await .expect("the pool serves after the contained panic"); assert_eq!(value, 7); diff --git a/libs/@local/graph/atlas/src/postgres/card/associations.rs b/libs/@local/graph/atlas/src/postgres/card/associations.rs index 33be77b65ae..45958983c94 100644 --- a/libs/@local/graph/atlas/src/postgres/card/associations.rs +++ b/libs/@local/graph/atlas/src/postgres/card/associations.rs @@ -691,7 +691,12 @@ fn association_statement<'params>( BoundStatement::new(&statement, binder, columns) } -fn cardinality(value: Option) -> Option { +/// Reads a link-target cardinality bound out of its nullable column. +/// +/// A null column is an unbounded end and answers [`None`]. The same applies to a stored value +/// that is not a count on this host - negative, or past `usize` - since neither constrains a +/// target list that has to fit in memory to be built. +const fn cardinality(value: Option) -> Option { usize::try_from(value?).ok() } diff --git a/libs/@local/graph/atlas/src/postgres/card/examples.rs b/libs/@local/graph/atlas/src/postgres/card/examples.rs index 9aeb38a7676..67335ff9be8 100644 --- a/libs/@local/graph/atlas/src/postgres/card/examples.rs +++ b/libs/@local/graph/atlas/src/postgres/card/examples.rs @@ -785,15 +785,20 @@ fn example_statement<'params>( BoundStatement::new(&statement, binder, columns) } -// A window count includes the row it annotates, so the value is at least -// 1 and the fallback never fires. -fn frequency(value: i64) -> u64 { +/// Reads a window count back as an unsigned frequency. +/// +/// The conversion accepts any `i64`. A non-negative value, including zero, converts to itself. +/// A negative value falls back to `1`. A window `COUNT` always includes the row it annotates: +/// its result is at least `1`, and at this call site the fallback never fires. +const fn frequency(value: i64) -> u64 { u64::try_from(value).unwrap_or(1) } -// The bound rides to Postgres as a bigint; a configuration large enough -// to overflow it saturates to "no bound". -fn pool_bound(count: usize, factor: usize) -> i64 { +/// Sizes the candidate pool a query draws examples from. +/// +/// The query binds the bound to Postgres as a bigint. A configuration large enough +/// to overflow it saturates to "no bound". +const fn pool_bound(count: usize, factor: usize) -> i64 { i64::try_from(count.saturating_mul(factor)).unwrap_or(i64::MAX) } diff --git a/libs/@local/graph/atlas/src/postgres/id.rs b/libs/@local/graph/atlas/src/postgres/id.rs index e6908816ff5..e7572fde3da 100644 --- a/libs/@local/graph/atlas/src/postgres/id.rs +++ b/libs/@local/graph/atlas/src/postgres/id.rs @@ -45,8 +45,8 @@ use crate::{ pub(crate) struct ArchivedEntityUuid([u8; 16]); impl ArchivedEntityUuid { - /// Wraps raw uuid bytes. - #[cfg(test)] // The serve and delta tests build archived identities from seed bytes. + /// Constructs an identity from UUID bytes. + #[cfg(test)] // serve document codec tests need identities with chosen byte patterns. pub(crate) const fn from_bytes(bytes: [u8; 16]) -> Self { Self(bytes) } @@ -117,8 +117,8 @@ impl Deref for ArchivedEntityUuid { pub(crate) struct ArchivedWebId([u8; 16]); impl ArchivedWebId { - /// Wraps raw uuid bytes. - #[cfg(test)] // The serve and delta tests build archived identities from seed bytes. + /// Constructs a web ID from UUID bytes. + #[cfg(test)] // serve document codec tests need identities with chosen byte patterns. pub(crate) const fn from_bytes(bytes: [u8; 16]) -> Self { Self(bytes) } @@ -221,6 +221,22 @@ impl From for EntityId { } } +impl serde::Serialize for ArchivedEntityId { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(&EntityId::from(*self)) + } +} + +impl schemars::JsonSchema for ArchivedEntityId { + fn schema_name() -> alloc::borrow::Cow<'static, str> { + "EntityId".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + String::json_schema(generator) + } +} + impl Key for ArchivedEntityId { type Payload = Legend; @@ -258,18 +274,9 @@ impl ArchivedOntologyTypeUuid { pub(crate) fn from_url(url: &VersionedUrl) -> Self { Self::from(OntologyTypeUuid::from_url(url).into_uuid()) } - - /// Views archived uuids through the store's uuid type, without copying. - pub(crate) const fn into_slice(slice: &[Self]) -> &[OntologyTypeUuid] { - // SAFETY: the inverse of `from_slice`'s cast, sound by the same transparent layout - // chain down to the shared 16-byte array on both sides. - unsafe { - core::slice::from_raw_parts(slice.as_ptr().cast::(), slice.len()) - } - } } -impl From for ArchivedOntologyTypeUuid { +const impl From for ArchivedOntologyTypeUuid { #[inline] fn from(id: uuid::Uuid) -> Self { Self(id.into_bytes()) diff --git a/libs/@local/graph/atlas/src/progress.rs b/libs/@local/graph/atlas/src/progress.rs index b5dd9f24dff..0245a13afaa 100644 --- a/libs/@local/graph/atlas/src/progress.rs +++ b/libs/@local/graph/atlas/src/progress.rs @@ -50,7 +50,7 @@ impl Stage { /// Every stage, in pipeline order. #[expect( clippy::cast_possible_truncation, - reason = "the index runs over the variant count, an order of magnitude inside u8" + reason = "the repr(u8) enum bounds its implicit discriminants to the u8 range" )] pub const ALL: [Self; core::mem::variant_count::()] = // SAFETY: A fieldless `repr(u8)` enum has u8 layout and admits its declared discriminants. @@ -117,8 +117,7 @@ pub struct DescentIteration { /// support concurrent callbacks. #[expect( unused_variables, - reason = "the default bodies observe nothing; the parameter names document each observation \ - for implementors" + reason = "the no-op defaults keep descriptive parameter names for implementors" )] pub trait Progress { /// An owned observer for work that cannot borrow this observer. diff --git a/libs/@local/graph/atlas/src/random/mod.rs b/libs/@local/graph/atlas/src/random/mod.rs index 2bfc7bd92cc..93182f22a28 100644 --- a/libs/@local/graph/atlas/src/random/mod.rs +++ b/libs/@local/graph/atlas/src/random/mod.rs @@ -221,8 +221,7 @@ pub(crate) fn acceptance_sample_size( #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, - reason = "the ratio of two negative logarithms is strictly positive and finite, and the \ - saturating float-to-integer conversion is the narrowing itself" + reason = "deliberately saturate the rounded floating-point budget to the usize range" )] let samples = samples as usize; diff --git a/libs/@local/graph/atlas/src/random/tests.rs b/libs/@local/graph/atlas/src/random/tests.rs index be3e5a1143c..850d137d145 100644 --- a/libs/@local/graph/atlas/src/random/tests.rs +++ b/libs/@local/graph/atlas/src/random/tests.rs @@ -10,7 +10,7 @@ use super::{ acceptance_sample_size, keyed_rng, mean_sample_size, normal_quantile, sample_ids, sample_indices_vec, uniform_below, }; -use crate::math::{OpenUnitFraction, d_non_negative, d_positive, open_unit_fraction}; +use crate::math::{OpenUnitFraction, d_non_negative, d_positive, nz, open_unit_fraction}; /// Creates a reproducible generator for a test case. fn rng(seed: u64) -> Xoshiro256PlusPlus { @@ -20,7 +20,7 @@ fn rng(seed: u64) -> Xoshiro256PlusPlus { #[test] fn uniform_below_bound_one() { let mut rng = rng(7); - let bound = NonZero::new(1).expect("one is not zero"); + let bound = nz!(1); for _ in 0..64 { assert_eq!(uniform_below(&mut rng, bound), 0); @@ -30,7 +30,7 @@ fn uniform_below_bound_one() { #[test] fn uniform_below_residue_balance() { let mut rng = rng(42); - let bound = NonZero::new(7).expect("seven is not zero"); + let bound = nz!(7); let mut counts = [0_u32; 7]; let draws = 70_000; diff --git a/libs/@local/graph/atlas/src/salt/adjacency/artifact.rs b/libs/@local/graph/atlas/src/salt/adjacency/artifact.rs index f29d3832f96..d4a79142795 100644 --- a/libs/@local/graph/atlas/src/salt/adjacency/artifact.rs +++ b/libs/@local/graph/atlas/src/salt/adjacency/artifact.rs @@ -1,18 +1,22 @@ use core::ops::Range; +use std::path::Path; use hashql_core::id::{Id as _, bit_vec::DenseBitSet}; use crate::{ - file::sprs::{ - IndexVariant, SprsIndex, - read::{SprsFile, SprsMatrixError}, + file::{ + ArtifactFile, + sprs::{ + IndexVariant, SprsIndex, + read::{OpenSprsError, SprsFile, SprsMatrixError}, + }, }, identity::{EdgeRowId, NodeRowId}, }; /// A failure while validating an adjacency's sparse-matrix representation. #[derive(Debug)] -pub enum InvalidAdjacencyFile { +pub(crate) enum InvalidAdjacencyFile { /// The file fails the published adjacency shape. /// /// The bytes are not the structure-only matrix the adjacency publishes, or the compressed @@ -73,7 +77,48 @@ impl core::error::Error for InvalidAdjacencyFile { } } -/// The index width an adjacency's edge row ids read at. +/// A failure to open or validate a mapped adjacency. +#[derive(Debug)] +pub(crate) enum OpenAdjacencyArchiveError { + /// The sparse matrix file failed to open. + Open(OpenSprsError), + /// The file does not hold a valid adjacency. + Invalid(InvalidAdjacencyFile), +} + +const impl From for OpenAdjacencyArchiveError { + fn from(error: OpenSprsError) -> Self { + Self::Open(error) + } +} + +const impl From for OpenAdjacencyArchiveError { + fn from(error: InvalidAdjacencyFile) -> Self { + Self::Invalid(error) + } +} + +impl core::fmt::Display for OpenAdjacencyArchiveError { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Open(error) => write!(fmt, "the sparse matrix file failed to open: {error}"), + Self::Invalid(error) => { + write!(fmt, "the file does not hold a valid adjacency: {error}") + } + } + } +} + +impl core::error::Error for OpenAdjacencyArchiveError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::Open(error) => Some(error), + Self::Invalid(error) => Some(error), + } + } +} + +/// The stored unsigned width of an adjacency's edge row ids. #[derive(Debug, Copy, Clone)] enum Width { U16, @@ -102,6 +147,15 @@ pub(crate) struct AdjacencyArchive { edges: u64, } +impl ArtifactFile for AdjacencyArchive { + type Error = OpenAdjacencyArchiveError; + + fn open(path: impl AsRef) -> Result { + let file = SprsFile::open(path)?; + Self::new(file).map_err(From::from) + } +} + impl AdjacencyArchive { /// Validates a CSR file for mapped incident-edge lookups. /// diff --git a/libs/@local/graph/atlas/src/salt/adjacency/mod.rs b/libs/@local/graph/atlas/src/salt/adjacency/mod.rs index 24752589373..6e78e365780 100644 --- a/libs/@local/graph/atlas/src/salt/adjacency/mod.rs +++ b/libs/@local/graph/atlas/src/salt/adjacency/mod.rs @@ -53,9 +53,7 @@ use std::io; use hashql_core::id::Id as _; use sprs::{CsMatBase, SpIndex}; -#[cfg(test)] -pub(crate) use self::artifact::EdgeList; -pub(crate) use self::artifact::{AdjacencyArchive, InvalidAdjacencyFile}; +pub(crate) use self::artifact::{AdjacencyArchive, EdgeList}; use crate::{ file::{ WriteAs, WriteInto, diff --git a/libs/@local/graph/atlas/src/salt/adjacency/tests.rs b/libs/@local/graph/atlas/src/salt/adjacency/tests.rs index 4f9cdfd46d7..6929eb9ec67 100644 --- a/libs/@local/graph/atlas/src/salt/adjacency/tests.rs +++ b/libs/@local/graph/atlas/src/salt/adjacency/tests.rs @@ -5,7 +5,7 @@ use camino::Utf8PathBuf; use hashql_core::id::Id as _; use sprs::CsMatViewI; -use super::{Adjacency, AdjacencyArchive, EdgeList, InvalidAdjacencyFile}; +use super::{Adjacency, AdjacencyArchive, EdgeList, artifact::InvalidAdjacencyFile}; use crate::{ file::{ WriteInto as _, @@ -191,7 +191,7 @@ fn violated_list_invariants_are_rejected() { #[test] #[expect( clippy::little_endian_bytes, - reason = "the surgery edits the format's pinned little-endian fencepost region" + reason = "the fixture edits the format's little-endian fencepost region" )] fn shifted_fencepost_column_is_rejected() { let dir = scratch("shifted"); diff --git a/libs/@local/graph/atlas/src/salt/embedding/external/tests.rs b/libs/@local/graph/atlas/src/salt/embedding/external/tests.rs index b85a149c6ee..d84385a6e2f 100644 --- a/libs/@local/graph/atlas/src/salt/embedding/external/tests.rs +++ b/libs/@local/graph/atlas/src/salt/embedding/external/tests.rs @@ -1,10 +1,9 @@ #![expect( clippy::float_cmp, - reason = "fixture vectors use exactly representable components, so ordering and conversion \ - must reproduce them bit-identically" + reason = "ordering and conversion must preserve the fixture's exactly representable components" )] use alloc::sync::Arc; -use core::{assert_matches, future::ready, num::NonZero}; +use core::{assert_matches, future::ready}; use std::sync::Mutex; use error_stack::Report; @@ -20,6 +19,7 @@ use crate::{ CANONICAL_DIMENSIONS, card::{Cl100kTokenizer, Tokenizer as _}, }, + math::nz, progress::{Batch, NoProgress, Progress}, salt::embedding::CardEmbedder as _, }; @@ -218,7 +218,7 @@ async fn splits_requests_at_the_document_ceiling() { generator, &contract(), RequestLimits { - documents: NonZero::new(2).expect("two is nonzero"), + documents: nz!(2), .. }, NoProgress, @@ -248,7 +248,7 @@ async fn every_completed_request_reports_its_position_in_the_workload() { RecordingGenerator::default(), &contract(), RequestLimits { - documents: NonZero::new(2).expect("two is nonzero"), + documents: nz!(2), .. }, progress.clone(), @@ -295,10 +295,7 @@ async fn splits_requests_at_the_token_ceiling() { let provider = ExternalEmbeddingProvider::new( generator, &contract(), - RequestLimits { - tokens: NonZero::new(2).expect("two is nonzero"), - .. - }, + RequestLimits { tokens: nz!(2), .. }, NoProgress, ); @@ -336,10 +333,7 @@ async fn splits_requests_at_the_byte_estimate_ceiling() { let provider = ExternalEmbeddingProvider::new( generator, &contract(), - RequestLimits { - tokens: NonZero::new(6).expect("six is nonzero"), - .. - }, + RequestLimits { tokens: nz!(6), .. }, NoProgress, ); @@ -362,10 +356,7 @@ async fn rejects_a_text_above_the_token_ceiling() { let provider = ExternalEmbeddingProvider::new( RecordingGenerator::default(), &contract(), - RequestLimits { - tokens: NonZero::new(1).expect("one is nonzero"), - .. - }, + RequestLimits { tokens: nz!(1), .. }, NoProgress, ); @@ -385,10 +376,7 @@ async fn rejects_a_text_above_the_byte_estimate_ceiling() { let provider = ExternalEmbeddingProvider::new( RecordingGenerator::default(), &contract(), - RequestLimits { - tokens: NonZero::new(3).expect("three is nonzero"), - .. - }, + RequestLimits { tokens: nz!(3), .. }, NoProgress, ); diff --git a/libs/@local/graph/atlas/src/salt/embedding/tests.rs b/libs/@local/graph/atlas/src/salt/embedding/tests.rs index f6d76d6b754..d41489d1125 100644 --- a/libs/@local/graph/atlas/src/salt/embedding/tests.rs +++ b/libs/@local/graph/atlas/src/salt/embedding/tests.rs @@ -1,7 +1,7 @@ #![expect( clippy::float_cmp, - reason = "fixture vectors use exactly representable components, so placement and round-trips \ - must reproduce them bit-identically" + reason = "placement and round-trips must preserve the fixture's exactly representable \ + components" )] use core::{assert_matches, future::ready}; use std::sync::Mutex; @@ -492,7 +492,7 @@ async fn three_row_table() -> super::CardEmbeddingTable { #[tokio::test] #[expect( clippy::little_endian_bytes, - reason = "the array format pins its data to canonical little-endian bytes" + reason = "the fixture decodes native f32 bytes assuming a little-endian test host" )] async fn writes_the_embedding_matrix_as_an_array_file() { let table = three_row_table().await; diff --git a/libs/@local/graph/atlas/src/salt/file/point.rs b/libs/@local/graph/atlas/src/salt/file/point.rs index 5ac2807199f..eb27bbb2c8a 100644 --- a/libs/@local/graph/atlas/src/salt/file/point.rs +++ b/libs/@local/graph/atlas/src/salt/file/point.rs @@ -4,7 +4,10 @@ use std::path::Path; use hashql_core::id::{Id, IdSlice}; use crate::{ - file::array::{ArrayFile, OpenArrayError}, + file::{ + ArtifactFile as _, + array::{ArrayFile, OpenArrayError}, + }, math::{FinitePointField, NonFinitePoint, Vec2}, }; diff --git a/libs/@local/graph/atlas/src/salt/file/vector.rs b/libs/@local/graph/atlas/src/salt/file/vector.rs index a741c5c444d..bf26ff5296c 100644 --- a/libs/@local/graph/atlas/src/salt/file/vector.rs +++ b/libs/@local/graph/atlas/src/salt/file/vector.rs @@ -4,7 +4,10 @@ use std::path::Path; use hashql_core::id::{Id, IdSlice}; use crate::{ - file::array::{ArrayFile, OpenArrayError}, + file::{ + ArtifactFile as _, + array::{ArrayFile, OpenArrayError}, + }, math::AlignedVecN, }; diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/classifier.rs b/libs/@local/graph/atlas/src/salt/fit/compute/classifier.rs index 8a0c8624445..c3799c011ea 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/classifier.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/classifier.rs @@ -218,7 +218,7 @@ impl AcquiredClassifier { corpus: source, assembly: Box::new(*evidence), fit: ClassifierFitSummary { - folds: context.config.policy.classifier_fit.folds, + folds: context.config.policy.classifier_fit.folds(), regularization: fitted.evidence.regularization, selection: fitted .evidence diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/coordinates.rs b/libs/@local/graph/atlas/src/salt/fit/compute/coordinates.rs index 4818804f8f3..65b7093dd91 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/coordinates.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/coordinates.rs @@ -6,6 +6,7 @@ use hashql_core::id::IdSlice; use crate::{ file::{ + ArtifactFile as _, array::{ArrayFile, OpenArrayError}, generation::StagedGeneration, repository::Binding, diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/error.rs b/libs/@local/graph/atlas/src/salt/fit/compute/error.rs index ed78b777579..ac6ea9e531a 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/error.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/error.rs @@ -25,6 +25,7 @@ use super::{ }; use crate::{ file::{generation::SealError, identity::read::OpenIdentityError}, + offload::OffloadError, salt::file::OpenVectorError, }; @@ -67,12 +68,13 @@ pub(crate) enum ComputeError { /// The delivery stage failed to derive or stage the served structure. Delivery(DeliveryError), /// The finished staging failed to seal into a generation. - Seal(SealError), - /// A stage panicked on the compute pool. /// - /// The payload's message survives, unwinding removes the staging directory, and the async - /// executor never observes the unwind. - Panicked { message: Option }, + /// The seal renames the staging directory into the generation root and then syncs the root. + /// A failure before the rename leaves nothing published, and a root open or sync failure + /// after it leaves the generation directory visible. + Seal(SealError), + /// The offload computation failed to complete. + Offload(OffloadError), } impl From for ComputeError { @@ -153,6 +155,12 @@ impl From for ComputeError { } } +impl From for ComputeError { + fn from(error: OffloadError) -> Self { + Self::Offload(error) + } +} + /// Formats one boundary artifact's map-in failure. fn map_in(fmt: &mut fmt::Formatter<'_>, artifact: &str, error: &dyn fmt::Display) -> fmt::Result { write!(fmt, "the staged {artifact} failed to map in: {error}") @@ -186,14 +194,8 @@ impl fmt::Display for ComputeError { write!(fmt, "the placement stage failed: {error}") } Self::Delivery(error) => write!(fmt, "the delivery stage failed: {error}"), - Self::Seal(error) => write!(fmt, "the generation failed to publish: {error}"), - Self::Panicked { message } => { - fmt.write_str("a stage panicked on the compute pool")?; - if let Some(message) = message { - write!(fmt, ": {message}")?; - } - Ok(()) - } + Self::Seal(error) => write!(fmt, "the generation failed to seal: {error}"), + Self::Offload(error) => write!(fmt, "the offload computation failed: {error}"), } } } @@ -215,7 +217,7 @@ impl Error for ComputeError { Self::Projector(error) => Some(error), Self::Delivery(error) => Some(error), Self::Seal(error) => Some(error), - Self::Panicked { .. } => None, + Self::Offload(error) => Some(error), } } } diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/landmark.rs b/libs/@local/graph/atlas/src/salt/fit/compute/landmark.rs index afca7fb08b9..d9bbb8fff8a 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/landmark.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/landmark.rs @@ -12,6 +12,7 @@ use super::{ use crate::{ dataset::PROJECTOR_DIMENSIONS, file::{ + ArtifactFile as _, generation::Generation, identity::{Key, read::IdentityFile}, landmark::read::LandmarkFile, @@ -20,7 +21,12 @@ use crate::{ identity::NodeRowId, math::DPositive, salt::{ - fit::{Stage, error::PriorError, prepare::identity::IdentityTableArchive, stage_rng}, + fit::{ + Stage, + error::PriorError, + prepare::{IdentityProvider as _, identity::IdentityTableArchive}, + stage_rng, + }, knn::hannoy::{HannoyIndex, HannoyIndexError}, landmark::{ artifact::{LandmarkSkeleton, LandmarkSkeletonArchive}, @@ -164,7 +170,7 @@ impl PriorMarks { ); for &row in skeleton.selected_rows() { let id = prior_ids - .id(row) + .key_of(row) .ok_or_else(|| PriorError::SkeletonBeyondIdentities { row: row.as_u64() })?; if let Some(current_row) = current.row_of(id) { diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/lod.rs b/libs/@local/graph/atlas/src/salt/fit/compute/lod.rs index 138984b36b3..bb2b51a6768 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/lod.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/lod.rs @@ -165,7 +165,7 @@ where // The column is present regardless, and the rank contract and the wire shape therefore do // not depend on whether a boost signal exists. let priority = IdVec::from_domain(0.0_f32, &importance); - let inputs = RankInputs::new(&importance, &priority, self.ids.ids()).ok_or_else(|| { + let inputs = RankInputs::new(&importance, &priority, self.ids.keys()).ok_or_else(|| { DeliveryError::WireEncoding { rows: self.ids.len(), } diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/mod.rs b/libs/@local/graph/atlas/src/salt/fit/compute/mod.rs index 787286a402d..b32ea3f1d54 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/mod.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/mod.rs @@ -58,6 +58,7 @@ use crate::{ dataset::{OntologyIdentity, PROJECTOR_DIMENSIONS}, device::PhysicalDevice, file::{ + ArtifactFile as _, generation::{Generation, PublishedGeneration, ScratchDirectory, StagedGeneration}, identity::{Key, read::IdentityFile}, repository::{Artifact as _, Binding, RepositoryVersion}, diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/projector/inputs.rs b/libs/@local/graph/atlas/src/salt/fit/compute/projector/inputs.rs index 5c2e26473dc..9ae5f743e4c 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/projector/inputs.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/projector/inputs.rs @@ -9,6 +9,7 @@ use super::super::{ use crate::{ dataset::{OntologyIdentity, PROJECTOR_DIMENSIONS}, file::{ + ArtifactFile as _, generation::StagedGeneration, identity::{Key, read::IdentityFile}, repository::Binding, @@ -139,7 +140,7 @@ impl VerdictResolution { let table = IdentityTableArchive::::new(IdentityFile::open(path.as_std_path())?)?; - let resolution = supplied.document().resolve(table.ids()); + let resolution = supplied.document().resolve(table.keys()); let unresolved = resolution.unresolved().len(); tracing::info!( resolved = resolution.resolved().len(), diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/projector/mod.rs b/libs/@local/graph/atlas/src/salt/fit/compute/projector/mod.rs index fad11535ff5..192abfa23c5 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/projector/mod.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/projector/mod.rs @@ -226,7 +226,7 @@ impl<'fit> PlacementPass<'fit> { let training_roles = IdVec::from_elem(NodeRole::KnowledgeEntity, training.len()); let landmarks = SupportAnchor::at_landmarks( self.inputs.skeleton, - options.landmark_support.weight(), + options.landmark_support.weight, |row| distinct.quotient.class_of(row), ); @@ -245,7 +245,7 @@ impl<'fit> PlacementPass<'fit> { // corpus. let vacuous = AttractionIndex::vacuous(); let attraction = if options.vacuous { - tracing::info!("vacuous attraction select. no attraction term will be used"); + tracing::info!("the placement is vacuous: training uses no attraction term"); &vacuous } else { &distinct.indexes.attraction @@ -439,9 +439,12 @@ impl<'fit> PlacementPass<'fit> { /// Stages the published model checkpoint. /// - /// Recording moves a clone of the parameters into the record, so the model still projects - /// the ladder after its checkpoint stages. - #[tracing::instrument(skip_all, ret)] + /// Consumes the training model to record its parameters. + /// + /// # Errors + /// + /// Returns [`ProjectorError`] when encoding or staging the checkpoint fails. + #[tracing::instrument(skip_all)] fn checkpoint( &self, model: Projector, diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/projector/report.rs b/libs/@local/graph/atlas/src/salt/fit/compute/projector/report.rs index ba361039409..ae9760a7b34 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/projector/report.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/projector/report.rs @@ -14,6 +14,7 @@ use super::{error::ProjectorError, inputs::PublishInputs}; use crate::{ device::{Inference, PhysicalDevice}, file::{ + ArtifactFile as _, array::{ArrayFile, ArrayVariant, Dim, SizedArrayWriter, SizedColumn}, attraction::read::AttractionFile, generation::{ScratchDirectory, StagedGeneration}, @@ -127,7 +128,7 @@ impl<'fit> LadderPass<'fit> { ); tracing::info!( - radius = %energy.proximal().radius(), + radius = %energy.proximal().radius, conditions = ?series.conditions, losses = ?series.losses, "measured the step relation losses" @@ -259,8 +260,9 @@ impl<'fit> LadderPass<'fit> { /// rather than a data condition, and no persisted refusal names it. #[expect( clippy::panic_in_result_fn, - reason = "the Result carries fit-level failures; a row-count contradiction between two \ - artifacts of one fit is a pipeline contract violation, documented under Panics" + reason = "the Result carries fit-level failures, and a row-count contradiction between \ + two artifacts of one fit is a pipeline contract violation documented under \ + Panics" )] fn measure_paired_movement( &self, diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/projector/tests.rs b/libs/@local/graph/atlas/src/salt/fit/compute/projector/tests.rs index 9e51d66178e..9575dbdeb7f 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/projector/tests.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/projector/tests.rs @@ -25,6 +25,7 @@ use crate::{ dataset::PROJECTOR_DIMENSIONS, device::{Device, Inference, Training}, file::{ + ArtifactFile as _, array::ArrayFile, generation::{GenerationRoot, StagedGeneration}, repository::Artifact as _, @@ -39,9 +40,9 @@ use crate::{ identity::{EdgeRowId, NodeRowId, OntologyRowId}, integrity::{Sha256, Update as _}, math::{ - AffinityCurve, AlignedVecN, BoxedVecN, FinitePointField, NonNegative, Positive, Similarity, + AffinityCurve, AlignedVecN, BoxedVecN, FinitePointField, NonNegative, Similarity, UnitFraction, Vec2, d_non_negative, d_positive, non_negative, nz, open_unit_fraction, - positive, positive_unit_fraction, + positive, positive_unit_fraction, unit_fraction, }, salt::{ embedding::EmbedderFingerprint, @@ -57,7 +58,8 @@ use crate::{ scale::{LocalScales, ScaledFrame}, train::{ BoundaryEvidence, BudgetBreakdown, FrozenRadius, Model, NodeColumns, - RefreshFraction, RelationLens, TrainingEvidence, TrainingSchedule, refresh, + RefreshFraction, RelationLens, TrainingEvidence, TrainingSchedule, + fit::TrainingScheduleOptions, refresh, }, verdict::calibrate::{ ProximalCalibration, @@ -221,7 +223,7 @@ fn stage_attraction(staging: &StagedGeneration) { /// The representation width stays the pipeline's contract while the hidden architecture /// shrinks: a forward pass costs a fraction of the `ratified()` model's. fn skinny_options() -> ProjectorOptions { - let mut options = ProjectorOptions::ratified(); + let mut options = ProjectorOptions::live(); options.architecture = Architecture { width: nz!(8), residual_blocks: nz!(1), @@ -229,19 +231,22 @@ fn skinny_options() -> ProjectorOptions { role_dimensions: nz!(4), condition_dimensions: nz!(1), }; - options.schedule = TrainingSchedule::new( - nz!(1), - 0, - nz!(1), - positive_unit_fraction!(1.0e-3), - UnitFraction::new(1.0e-5).expect("the fixture minimum rate is a unit fraction"), - ) + options.schedule = TrainingSchedule::new(TrainingScheduleOptions { + steps: nz!(1), + boundary: 0, + refresh_interval: nz!(1), + initial_learning_rate: positive_unit_fraction!(1.0e-3), + minimum_learning_rate: unit_fraction!(1.0e-5), + }) .expect("the fixture schedule is valid"); - options.lens = RelationLens::new( - CoincidentEnergy::new(non_negative!(0.01), positive!(0.5)), - Positive::new(0.25).expect("the fixture temperature is positive"), - Positive::new(1.0e-8).expect("the fixture scale guard is positive"), - ); + options.lens = RelationLens { + coincident: CoincidentEnergy { + radius: non_negative!(0.01), + threshold: positive!(0.5), + }, + temperature: positive!(0.25), + epsilon: positive!(1.0e-8), + }; options.ladder.conditions = Conditions::new(vec![NonNegative::ZERO, NonNegative::ONE]) .expect("the fixture schedule is valid"); options.ladder.canonical = NonNegative::ONE; @@ -673,7 +678,7 @@ fn fit_config() -> FitConfig { #[test] #[expect( clippy::significant_drop_tightening, - reason = "the staging directory is read back after the publish returns; dropping it early \ + reason = "the staging directory is read back after the publish returns, and dropping it early \ would delete the files under assertion" )] fn publish_vacuous_baseline() { @@ -792,7 +797,7 @@ fn publish_vacuous_baseline() { #[test] #[expect( clippy::significant_drop_tightening, - reason = "the staging directory is read back after the publish returns; dropping it early \ + reason = "the staging directory is read back after the publish returns, and dropping it early \ would delete the files under assertion" )] fn publish_measured_aligned_canonical() { diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/quotient.rs b/libs/@local/graph/atlas/src/salt/fit/compute/quotient.rs index b5dd4ea6c83..1f577d129ee 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/quotient.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/quotient.rs @@ -312,8 +312,8 @@ impl<'corpus, const N: usize> Quotient<'corpus, N> { #[cfg(test)] #[expect( clippy::significant_drop_tightening, - reason = "each test's scratch directory backs the quotient's mapped distinct matrix, so it \ - lives to the end of the assertions on purpose" + reason = "each test's scratch directory backs the quotient's mapped distinct matrix and \ + therefore lives to the end of the assertions on purpose" )] mod tests { use hashql_core::id::Id as _; diff --git a/libs/@local/graph/atlas/src/salt/fit/compute/relation.rs b/libs/@local/graph/atlas/src/salt/fit/compute/relation.rs index 433e0dbf86c..46d85d7aea2 100644 --- a/libs/@local/graph/atlas/src/salt/fit/compute/relation.rs +++ b/libs/@local/graph/atlas/src/salt/fit/compute/relation.rs @@ -10,6 +10,7 @@ use super::{ use crate::{ dataset::PROJECTOR_DIMENSIONS, file::{ + ArtifactFile as _, array::{ArrayFile, OpenArrayError}, repository::{Artifact as _, Binding}, salt::artifact, diff --git a/libs/@local/graph/atlas/src/salt/fit/echo.rs b/libs/@local/graph/atlas/src/salt/fit/echo.rs deleted file mode 100644 index ef85e1c8518..00000000000 --- a/libs/@local/graph/atlas/src/salt/fit/echo.rs +++ /dev/null @@ -1,1046 +0,0 @@ -//! The serialized form of one fit's configuration. -//! -//! The metadata document echoes the whole [`FitConfig`], so a replay takes every setting from the -//! published record instead of the defaults compiled into the replaying binary. `serde_derive` does -//! not parse default field values, so the options structs cannot carry their own derives; each has -//! a field-for-field shadow here (the [serde remote pattern]). Deserialization constructs the real -//! struct field by field: an option field added to a stage fails compilation here until the echo -//! carries it. -//! -//! Validated fields ([`UnitFraction`], [`Positive`], [`NonNegative`], [`AffinityCurve`], -//! the `NonZero` counts) deserialize through their validating constructors, so a document whose -//! echo violates a construction invariant refuses to parse. The `math` types serialize through the -//! with-modules here rather than own impls: `math` is serialization-free, and how a document spells -//! a curve or a fraction is the document's concern. -//! -//! [serde remote pattern]: https://serde.rs/remote-derive.html - -use core::{num::NonZero, time::Duration}; - -use super::{FitConfig, KnnConstructionChoice, PlacementOptions, PolicyOptions, prepare::norm}; -use crate::{ - math::{AffinityCurve, DPositive, Log2, NonNegative, OpenUnitFraction, Positive, UnitFraction}, - salt::{ - importance::RankingConfig, - knn::{descent::NnDescentOptions, hannoy::HannoyIndexOptions, recall}, - landmark::{layout::LayoutOptions, quotient::QuotientOptions, select::SelectionOptions}, - lod::stage::LodConfig, - policy::{ - CoincidentAdmission, PolicyOverride, annotation::assembly::AssemblyConfig, - classifier::FitConfig as ClassifierFitConfig, - }, - relation::attraction::AttractionOptions, - semantic::SmoothingOptions, - }, -}; - -/// Serializes a [`Duration`] as whole and fractional seconds. -/// -/// Refuses a negative, infinite, or `NaN` reading on deserialize: the wire carries an allowance of -/// wall clock, and only a finite non-negative span is one. -mod seconds { - use core::time::Duration; - - use serde::{Deserialize as _, de::Error as _}; - - pub(super) fn serialize(budget: &Duration, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_f64(budget.as_secs_f64()) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let seconds = f64::deserialize(deserializer)?; - Duration::try_from_secs_f64(seconds).map_err(|error| { - D::Error::custom(format_args!("{seconds} is not a span of seconds: {error}")) - }) - } -} - -/// Serializes a [`Log2`] as its plain exponent. -/// -/// Validates through [`Log2::new`] on deserialize. -mod log2 { - use serde::{Deserialize as _, de::Error as _}; - - use crate::math::Log2; - - #[expect( - clippy::trivially_copy_pass_by_ref, - reason = "serde's `with` contract passes the field by reference" - )] - pub(super) fn serialize(exponent: &Log2, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_u8(exponent.get()) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = u8::deserialize(deserializer)?; - Log2::new(value).ok_or_else(|| { - D::Error::custom(format_args!( - "the exponent {value} is not below the u64 shift width" - )) - }) - } -} - -/// Serializes an [`AffinityCurve`] as its two named parameters. -/// -/// Validates through [`AffinityCurve::new`] on deserialize. -mod affinity_curve { - #![expect( - clippy::min_ident_chars, - reason = "`a` and `b` are the canonical names of the UMAP curve parameters and the \ - document's field names" - )] - - use serde::{Deserialize as _, Serialize as _, de::Error as _}; - - use crate::math::{AffinityCurve, Positive}; - - /// The curve's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct Record { - a: f32, - b: f32, - } - - #[expect( - clippy::trivially_copy_pass_by_ref, - reason = "serde's `with` contract passes the field by reference" - )] - pub(super) fn serialize(curve: &AffinityCurve, serializer: S) -> Result - where - S: serde::Serializer, - { - Record { - a: curve.a().get(), - b: curve.b().get(), - } - .serialize(serializer) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let Record { a, b } = Record::deserialize(deserializer)?; - match (Positive::new(a), Positive::new(b)) { - (Some(a), Some(b)) => Ok(AffinityCurve::new(a, b)), - _ => Err(D::Error::custom(format_args!( - "the parameters a = {a}, b = {b} do not form an affinity curve; both must be \ - finite and strictly positive" - ))), - } - } -} - -/// Serializes policy overrides as named records. -/// -/// Validates each distribution through [`Posterior::new`] on deserialize. -/// -/// [`Posterior::new`]: crate::salt::policy::Posterior::new -mod policy_overrides { - use hashql_core::id::Id as _; - use serde::{Deserialize as _, Serialize as _, de::Error as _}; - - use crate::{ - identity::OntologyRowId, - salt::policy::{GeometryClass, PolicyOverride, PolicySource, Posterior}, - }; - - /// The precedence tier's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - #[serde(rename_all = "kebab-case")] - enum SourceRecord { - Human, - Reviewed, - Synthetic, - } - - /// One override's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct Record { - relation: u64, - source: SourceRecord, - distribution: [f64; GeometryClass::COUNT], - } - - #[expect( - clippy::ptr_arg, - reason = "serde's `with` contract passes the field type by reference" - )] - pub(super) fn serialize( - overrides: &Vec, - serializer: S, - ) -> Result - where - S: serde::Serializer, - { - let records: Vec = overrides - .iter() - .map(|record| Record { - relation: record.relation.as_u64(), - source: match record.source { - PolicySource::Human => SourceRecord::Human, - PolicySource::Reviewed => SourceRecord::Reviewed, - PolicySource::Synthetic => SourceRecord::Synthetic, - }, - distribution: record.distribution.to_array(), - }) - .collect(); - records.serialize(serializer) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: serde::Deserializer<'de>, - { - Vec::::deserialize(deserializer)? - .into_iter() - .map(|record| { - let distribution = Posterior::new(record.distribution).ok_or_else(|| { - D::Error::custom(format_args!( - "the override for relation row {} does not assert a probability \ - distribution over the geometry classes", - record.relation, - )) - })?; - Ok(PolicyOverride { - relation: OntologyRowId::new(record.relation), - source: match record.source { - SourceRecord::Human => PolicySource::Human, - SourceRecord::Reviewed => PolicySource::Reviewed, - SourceRecord::Synthetic => PolicySource::Synthetic, - }, - distribution, - }) - }) - .collect() - } -} - -/// Serializes [`AttractionOptions`] as its two named settings. -/// -/// The typed fields refuse out-of-domain values at deserialize. -mod attraction_options { - use serde::{Deserialize as _, Serialize as _}; - - use crate::{math::NonNegative, salt::relation::attraction::AttractionOptions}; - - /// The settings' wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct Record { - coincident_coefficient: NonNegative, - pruning_threshold: NonNegative, - } - - #[expect( - clippy::trivially_copy_pass_by_ref, - reason = "serde's `with` contract passes the field by reference" - )] - pub(super) fn serialize( - options: &AttractionOptions, - serializer: S, - ) -> Result - where - S: serde::Serializer, - { - Record { - coincident_coefficient: options.coincident_coefficient(), - pruning_threshold: options.pruning_threshold(), - } - .serialize(serializer) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let Record { - coincident_coefficient, - pruning_threshold, - } = Record::deserialize(deserializer)?; - Ok(AttractionOptions::new( - coincident_coefficient, - pruning_threshold, - )) - } -} - -/// Serializes [`PlacementOptions`] as a tagged record. -/// -/// Validates every projector setting through its constructor on deserialize. -mod placement { - use core::num::NonZero; - - use serde::{Deserialize as _, Serialize as _}; - - use super::super::{LandmarkSupport, PlacementOptions, ProjectorOptions}; - use crate::{ - math::{NonNegative, Positive, PositiveUnitFraction, UnitFraction}, - salt::{ - ladder::{Conditions, LadderOptions}, - projector::{ - budget::Budget, - loss::{CoincidentEnergy, SupportOptions}, - miner::MinerOptions, - model::Architecture, - train::{BatchPlan, Coefficients, RelationLens, TrainingSchedule}, - }, - relation::protection::{ChannelConfig, ProtectionConfig}, - }, - }; - - /// The placement's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - #[serde(rename_all = "kebab-case")] - enum Record { - LandmarkBaseline, - Projector(Box), - } - - /// The budget's wire form. - /// - /// Writers emit the floor object. Readers additionally accept the retired clamp's bare - /// four-constant array `[positive, total, floor, epsilon]`, taking its floor and dropping the - /// rest, so every published manifest deserializes. The untagged split is structural (array - /// against object), so either form reads back unambiguously. - #[derive(serde::Serialize, serde::Deserialize)] - #[serde(untagged)] - enum BudgetRecord { - Enforced([f32; 4]), - Observed { floor: f32 }, - } - - /// The projector settings' wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct ProjectorRecord { - architecture: ArchitectureRecord, - schedule: ScheduleRecord, - plan: PlanRecord, - affinity_offset: Positive, - support: [Positive; 2], - budget: BudgetRecord, - coefficients: [f32; 6], - miner: MinerRecord, - lens: LensRecord, - protection: ProtectionRecord, - landmark_weight: f32, - forward_rows: NonZero, - ladder: LadderRecord, - vacuous: bool, - } - - /// The model shape's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct ArchitectureRecord { - width: NonZero, - residual_blocks: NonZero, - representation_dimensions: NonZero, - role_dimensions: NonZero, - condition_dimensions: NonZero, - } - - /// The step schedule's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct ScheduleRecord { - steps: NonZero, - boundary: usize, - refresh_interval: NonZero, - initial_learning_rate: PositiveUnitFraction, - minimum_learning_rate: UnitFraction, - } - - /// The sampling plan's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct PlanRecord { - semantic_pairs: NonZero, - ordinary_pairs: usize, - relation_types: usize, - relation_cap: NonZero, - hard_queries: usize, - landmark_anchors: usize, - temporal_anchors: usize, - } - - /// The mining schedule's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct MinerRecord { - neighbours: NonZero, - search_margin: NonZero, - maximum_weight: f32, - rank_exponent: f32, - } - - /// The relation lens's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct LensRecord { - coincident_radius: NonNegative, - coincident_threshold: Positive, - temperature: Positive, - epsilon: Positive, - } - - /// The protection thresholds' wire form; each channel is `[floor, threshold]`. - #[derive(serde::Serialize, serde::Deserialize)] - struct ProtectionRecord { - hard: [f32; 2], - ordinary: [f32; 2], - protect_ordinary: bool, - } - - /// The condition ladder's wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct LadderRecord { - conditions: Vec, - canonical: NonNegative, - } - - pub(super) fn serialize( - placement: &PlacementOptions, - serializer: S, - ) -> Result - where - S: serde::Serializer, - { - let record = match placement { - PlacementOptions::LandmarkBaseline => Record::LandmarkBaseline, - PlacementOptions::Projector(options) => Record::Projector(Box::new(ProjectorRecord { - architecture: ArchitectureRecord { - width: options.architecture.width, - residual_blocks: options.architecture.residual_blocks, - representation_dimensions: options.architecture.representation_dimensions, - role_dimensions: options.architecture.role_dimensions, - condition_dimensions: options.architecture.condition_dimensions, - }, - schedule: ScheduleRecord { - steps: options.schedule.steps(), - boundary: options.schedule.boundary(), - refresh_interval: options.schedule.refresh_interval(), - initial_learning_rate: options.schedule.initial_learning_rate(), - minimum_learning_rate: options.schedule.minimum_learning_rate(), - }, - plan: PlanRecord { - semantic_pairs: options.plan.semantic_pairs, - ordinary_pairs: options.plan.ordinary_pairs, - relation_types: options.plan.relation_types, - relation_cap: options.plan.relation_cap, - hard_queries: options.plan.hard_queries, - landmark_anchors: options.plan.landmark_anchors, - temporal_anchors: options.plan.temporal_anchors, - }, - affinity_offset: options.affinity_offset, - support: [options.support.threshold(), options.support.epsilon()], - budget: BudgetRecord::Observed { - floor: options.budget.floor.get(), - }, - coefficients: [ - options.coefficients.semantic().get(), - options.coefficients.ordinary().get(), - options.coefficients.hard().get(), - options.coefficients.relation().get(), - options.coefficients.anchor().get(), - options.coefficients.landmark().get(), - ], - miner: MinerRecord { - neighbours: options.miner.neighbours(), - search_margin: options.miner.search_margin(), - maximum_weight: options.miner.maximum_weight(), - rank_exponent: options.miner.rank_exponent(), - }, - lens: LensRecord { - coincident_radius: options.lens.coincident().radius(), - coincident_threshold: options.lens.coincident().threshold(), - temperature: options.lens.temperature(), - epsilon: options.lens.epsilon(), - }, - protection: ProtectionRecord { - hard: [ - options.protection.hard().floor(), - options.protection.hard().threshold(), - ], - ordinary: [ - options.protection.ordinary().floor(), - options.protection.ordinary().threshold(), - ], - protect_ordinary: options.protection.protect_ordinary(), - }, - landmark_weight: options.landmark_support.weight(), - forward_rows: options.forward_rows, - ladder: LadderRecord { - conditions: options.ladder.conditions.values().to_vec(), - canonical: options.ladder.canonical, - }, - vacuous: options.vacuous, - })), - }; - record.serialize(serializer) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - match Record::deserialize(deserializer)? { - Record::LandmarkBaseline => Ok(PlacementOptions::LandmarkBaseline), - Record::Projector(record) => Ok(PlacementOptions::Projector(record.into_options()?)), - } - } - - /// Validates the wire coefficient array into the typed coefficients. - fn coefficients(wire: [f32; 6]) -> Result { - let [semantic, ordinary, hard, relation, anchor, landmark] = wire; - let semantic = Positive::new(semantic).ok_or_else(|| { - E::custom("the semantic coefficient is not finite and strictly positive") - })?; - let [ - Some(ordinary), - Some(hard), - Some(relation), - Some(anchor), - Some(landmark), - ] = [ordinary, hard, relation, anchor, landmark].map(NonNegative::new) - else { - return Err(E::custom( - "a non-semantic coefficient is not finite and non-negative", - )); - }; - Ok(Coefficients::new( - semantic, ordinary, hard, relation, anchor, landmark, - )) - } - - impl ProjectorRecord { - /// Validates the wire fields into the typed options. - /// - /// Field by field through the constructors. - fn into_options(self) -> Result { - let record = self; - - let schedule = TrainingSchedule::new( - record.schedule.steps, - record.schedule.boundary, - record.schedule.refresh_interval, - record.schedule.initial_learning_rate, - record.schedule.minimum_learning_rate, - ) - .ok_or_else(|| E::custom("the schedule fields do not form a training schedule"))?; - - let [threshold, epsilon] = record.support; - let support = SupportOptions::new(threshold, epsilon); - - let floor = match record.budget { - // The bare-array form carries the floor third, and the destructuring discards its - // other three constants. - BudgetRecord::Enforced([_, _, floor, _]) | BudgetRecord::Observed { floor } => { - floor - } - }; - let budget = Budget { - floor: Positive::new(floor).ok_or_else(|| { - E::custom("the budget floor is not finite and strictly positive") - })?, - }; - - let coefficients = coefficients(record.coefficients)?; - - let miner = MinerOptions::new( - record.miner.neighbours, - record.miner.search_margin, - Positive::new(record.miner.maximum_weight).ok_or_else(|| { - E::custom("the miner weight bound is not finite and strictly positive") - })?, - Positive::new(record.miner.rank_exponent).ok_or_else(|| { - E::custom("the miner rank exponent is not finite and strictly positive") - })?, - ); - - let lens = record.lens.into_lens(); - let protection = record.protection.into_config()?; - - let landmark_support = - LandmarkSupport::new(record.landmark_weight).ok_or_else(|| { - E::custom("the landmark support weight is not finite and strictly positive") - })?; - - let conditions = Conditions::new(record.ladder.conditions) - .map_err(|error| E::custom(format_args!("invalid condition schedule: {error}")))?; - - Ok(ProjectorOptions { - architecture: Architecture { - width: record.architecture.width, - residual_blocks: record.architecture.residual_blocks, - representation_dimensions: record.architecture.representation_dimensions, - role_dimensions: record.architecture.role_dimensions, - condition_dimensions: record.architecture.condition_dimensions, - }, - schedule, - plan: BatchPlan { - semantic_pairs: record.plan.semantic_pairs, - ordinary_pairs: record.plan.ordinary_pairs, - relation_types: record.plan.relation_types, - relation_cap: record.plan.relation_cap, - hard_queries: record.plan.hard_queries, - landmark_anchors: record.plan.landmark_anchors, - temporal_anchors: record.plan.temporal_anchors, - }, - affinity_offset: record.affinity_offset, - support, - budget, - coefficients, - miner, - lens, - protection, - landmark_support, - forward_rows: record.forward_rows, - ladder: LadderOptions { - conditions, - canonical: record.ladder.canonical, - }, - vacuous: record.vacuous, - }) - } - } - - impl LensRecord { - /// Composes the typed wire fields into the relation lens. - const fn into_lens(self) -> RelationLens { - RelationLens::new( - CoincidentEnergy::new(self.coincident_radius, self.coincident_threshold), - self.temperature, - self.epsilon, - ) - } - } - - impl ProtectionRecord { - /// Validates the wire fields into the protection configuration. - fn into_config(self) -> Result { - let channel = |[floor, threshold]: [f32; 2], name| { - ChannelConfig::new(floor, threshold).ok_or_else(|| { - E::custom(format_args!( - "the {name} channel fields do not form a protection channel" - )) - }) - }; - ProtectionConfig::new( - channel(self.hard, "hard")?, - channel(self.ordinary, "ordinary")?, - self.protect_ordinary, - ) - .ok_or_else(|| E::custom("the channels violate the protection ordering constraints")) - } - } -} - -/// serde shadow of [`SelectionOptions`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "SelectionOptions")] -struct SelectionOptionsDef { - maximum_count: NonZero, - retained_fraction: UnitFraction, - parallel_chunk: NonZero, -} - -/// serde shadow of [`norm::SpotCheckOptions`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "norm::SpotCheckOptions")] -struct NormCheckOptionsDef { - tolerance: DPositive, - defect_rate: OpenUnitFraction, - confidence: OpenUnitFraction, -} - -/// serde shadow of [`HannoyIndexOptions`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "HannoyIndexOptions")] -struct HannoyIndexOptionsDef { - map_size: usize, - ef_construction: usize, - ef_search: usize, -} - -/// serde shadow of [`KnnConstructionChoice`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "KnnConstructionChoice")] -enum KnnConstructionChoiceDef { - Index, - Descent(#[serde(with = "NnDescentOptionsDef")] NnDescentOptions), -} - -/// serde shadow of [`NnDescentOptions`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "NnDescentOptions")] -struct NnDescentOptionsDef { - maximum_candidates: usize, - maximum_iterations: usize, - termination: f64, -} - -/// serde shadow of [`recall::SpotCheckOptions`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "recall::SpotCheckOptions")] -struct RecallCheckOptionsDef { - neighbours: NonZero, - minimum_recall: UnitFraction, - confidence: OpenUnitFraction, - pilot: NonZero, - #[serde(with = "seconds")] - budget: Duration, -} - -/// serde shadow of [`SmoothingOptions`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "SmoothingOptions")] -struct SmoothingOptionsDef { - tolerance: DPositive, - bandwidth_floor: f32, - bisection_iterations: usize, -} - -/// serde shadow of [`QuotientOptions`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "QuotientOptions")] -struct QuotientOptionsDef { - maximum_neighbours: NonZero, -} - -/// serde shadow of [`LayoutOptions`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "LayoutOptions")] -struct LayoutOptionsDef { - epochs: NonZero, - initial_learning_rate: Positive, - repulsion_strength: NonNegative, - negative_sample_rate: NonZero, -} - -/// serde shadow of [`LodConfig`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "LodConfig")] -struct LodConfigDef { - // Published manifests carry the suffixed key, and the rename pins the wire name independent of - // the field name. - #[serde(with = "log2", rename = "span_log2")] - span: Log2, - max_tile_depth: u8, -} - -/// serde shadow of [`RankingConfig`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "RankingConfig")] -#[serde(rename_all = "kebab-case")] -enum RankingConfigDef { - ConstantColumns, - IncidentDegree, -} - -/// serde shadow of [`CoincidentAdmission`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "CoincidentAdmission")] -struct CoincidentAdmissionDef { - enforced: bool, - class_probability_threshold: UnitFraction, - applicability_threshold: UnitFraction, -} - -/// serde shadow of [`PolicyOptions`]. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "PolicyOptions")] -struct PolicyOptionsDef { - #[serde(with = "policy_overrides")] - overrides: Vec, - #[serde(with = "CoincidentAdmissionDef")] - admission: CoincidentAdmission, - #[serde(with = "assembly_config")] - assembly: AssemblyConfig, - #[serde(with = "classifier_fit_config")] - classifier_fit: ClassifierFitConfig, -} - -/// serde shadow of [`AssemblyConfig`]. -/// -/// Deserialization revalidates the budget's domain. -mod assembly_config { - use serde::{Deserialize as _, Serialize as _, de::Error as _}; - - use crate::salt::policy::annotation::assembly::AssemblyConfig; - - /// The assembly settings' wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct Record { - maximum_group_fraction: f64, - } - - #[expect( - clippy::trivially_copy_pass_by_ref, - reason = "serde's `with` contract passes the field by reference" - )] - pub(super) fn serialize(config: &AssemblyConfig, serializer: S) -> Result - where - S: serde::Serializer, - { - Record { - maximum_group_fraction: config.maximum_group_fraction, - } - .serialize(serializer) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let record = Record::deserialize(deserializer)?; - if !(record.maximum_group_fraction > 0.0 && record.maximum_group_fraction <= 1.0) { - return Err(D::Error::custom( - "the group budget must be a fraction in (0, 1]", - )); - } - - Ok(AssemblyConfig { - maximum_group_fraction: record.maximum_group_fraction, - }) - } -} - -/// serde shadow of the classifier's fit configuration. -/// -/// The wire form mirrors the typed configuration, echoing every validated scalar as its plain -/// value. Deserialization rebuilds each field through its validating constructor before -/// revalidating the cross-field constraints through the configuration's own domain check. -mod classifier_fit_config { - use core::num::NonZero; - - use serde::{Deserialize as _, Serialize as _, de::Error as _}; - - use crate::{ - math::{DNonNegative, DPositive, GreaterThanOne, OpenUnitFraction}, - salt::policy::classifier::{FitConfig, PreparationSettings, SolverConfig}, - }; - - /// The preparation knobs' wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct PreparationRecord { - regularization: f64, - target_sum_tolerance_ulps: u32, - curvature_relative_floor: f64, - } - - /// The solver knobs' wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct SolverRecord { - preparation: PreparationRecord, - radius_minimum: f64, - radius_initial: f64, - radius_maximum: f64, - shrink_factor: f64, - expansion_factor: f64, - eta_accept: f64, - eta_expand: f64, - relative_scaled_gradient_tolerance: f64, - absolute_scaled_gradient_tolerance: f64, - objective_resolution_ulps: u32, - curvature_guard_ulps: u32, - maximum_outer_iterations: u64, - } - - /// The fit settings' wire form. - #[derive(serde::Serialize, serde::Deserialize)] - struct Record { - solver: SolverRecord, - folds: usize, - seed: u64, - } - - pub(super) fn serialize(config: &FitConfig, serializer: S) -> Result - where - S: serde::Serializer, - { - let solver = &config.solver; - Record { - solver: SolverRecord { - preparation: PreparationRecord { - regularization: solver.preparation.regularization.get(), - target_sum_tolerance_ulps: solver.preparation.target_sum_tolerance_ulps.get(), - curvature_relative_floor: solver.preparation.curvature_relative_floor.get(), - }, - radius_minimum: solver.radius_minimum.get(), - radius_initial: solver.radius_initial.get(), - radius_maximum: solver.radius_maximum.get(), - shrink_factor: solver.shrink_factor.get(), - expansion_factor: solver.expansion_factor.get(), - eta_accept: solver.eta_accept.get(), - eta_expand: solver.eta_expand.get(), - relative_scaled_gradient_tolerance: solver.relative_scaled_gradient_tolerance.get(), - absolute_scaled_gradient_tolerance: solver.absolute_scaled_gradient_tolerance.get(), - objective_resolution_ulps: solver.objective_resolution_ulps.get(), - curvature_guard_ulps: solver.curvature_guard_ulps.get(), - maximum_outer_iterations: solver.maximum_outer_iterations.get(), - }, - folds: config.folds, - seed: config.seed, - } - .serialize(serializer) - } - - /// A positive double rebuilt from its echoed value. - fn positive(field: &str, value: f64) -> Result - where - E: serde::de::Error, - { - DPositive::new(value).ok_or_else(|| { - E::custom(format_args!( - "the echoed {field} {value} is not positive and finite" - )) - }) - } - - /// A non-negative double rebuilt from its echoed value. - fn non_negative(field: &str, value: f64) -> Result - where - E: serde::de::Error, - { - DNonNegative::new(value).ok_or_else(|| { - E::custom(format_args!( - "the echoed {field} {value} is not non-negative and finite" - )) - }) - } - - /// An open unit fraction rebuilt from its echoed value. - fn fraction(field: &str, value: f64) -> Result - where - E: serde::de::Error, - { - OpenUnitFraction::new(value) - .ok_or_else(|| E::custom(format_args!("the echoed {field} {value} is not in (0, 1)"))) - } - - /// A factor beyond one rebuilt from its echoed value. - fn beyond_one(field: &str, value: f64) -> Result - where - E: serde::de::Error, - { - GreaterThanOne::new(value).ok_or_else(|| { - E::custom(format_args!( - "the echoed {field} {value} is not greater than one" - )) - }) - } - - /// A non-zero 32-bit count rebuilt from its echoed value. - fn non_zero_u32(field: &str, value: u32) -> Result, E> - where - E: serde::de::Error, - { - NonZero::new(value) - .ok_or_else(|| E::custom(format_args!("the echoed {field} {value} is zero"))) - } - - /// A non-zero 64-bit budget rebuilt from its echoed value. - fn non_zero_u64(field: &str, value: u64) -> Result, E> - where - E: serde::de::Error, - { - NonZero::new(value) - .ok_or_else(|| E::custom(format_args!("the echoed {field} {value} is zero"))) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let record = Record::deserialize(deserializer)?; - let solver = &record.solver; - let config = FitConfig { - solver: SolverConfig { - preparation: PreparationSettings { - regularization: positive("regularization", solver.preparation.regularization)?, - target_sum_tolerance_ulps: non_zero_u32( - "target-sum tolerance", - solver.preparation.target_sum_tolerance_ulps, - )?, - curvature_relative_floor: positive( - "relative curvature floor", - solver.preparation.curvature_relative_floor, - )?, - }, - radius_minimum: positive("minimum radius", solver.radius_minimum)?, - radius_initial: positive("initial radius", solver.radius_initial)?, - radius_maximum: positive("maximum radius", solver.radius_maximum)?, - shrink_factor: fraction("shrink factor", solver.shrink_factor)?, - expansion_factor: beyond_one("expansion factor", solver.expansion_factor)?, - eta_accept: fraction("acceptance threshold", solver.eta_accept)?, - eta_expand: fraction("expansion threshold", solver.eta_expand)?, - relative_scaled_gradient_tolerance: fraction( - "relative gradient tolerance", - solver.relative_scaled_gradient_tolerance, - )?, - absolute_scaled_gradient_tolerance: non_negative( - "absolute gradient tolerance", - solver.absolute_scaled_gradient_tolerance, - )?, - objective_resolution_ulps: non_zero_u32( - "objective resolution", - solver.objective_resolution_ulps, - )?, - curvature_guard_ulps: non_zero_u32("curvature guard", solver.curvature_guard_ulps)?, - maximum_outer_iterations: non_zero_u64( - "outer iteration budget", - solver.maximum_outer_iterations, - )?, - }, - folds: record.folds, - seed: record.seed, - }; - config.validate().map_err(D::Error::custom)?; - - Ok(config) - } -} - -/// serde shadow of [`FitConfig`]: the metadata document's echo of every setting one fit ran under. -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(remote = "FitConfig")] -pub(crate) struct FitConfigDef { - seed: u64, - #[serde(with = "SelectionOptionsDef")] - selection: SelectionOptions, - #[serde(with = "affinity_curve")] - curve: AffinityCurve, - #[serde(with = "NormCheckOptionsDef")] - norm_check: norm::SpotCheckOptions, - neighbours: NonZero, - #[serde(with = "KnnConstructionChoiceDef")] - construction: KnnConstructionChoice, - #[serde(with = "HannoyIndexOptionsDef")] - index: HannoyIndexOptions, - #[serde(with = "RecallCheckOptionsDef")] - recall_check: recall::SpotCheckOptions, - #[serde(with = "SmoothingOptionsDef")] - smoothing: SmoothingOptions, - #[serde(with = "QuotientOptionsDef")] - quotient: QuotientOptions, - #[serde(with = "LayoutOptionsDef")] - layout: LayoutOptions, - #[serde(with = "PolicyOptionsDef")] - policy: PolicyOptions, - #[serde(with = "attraction_options")] - attraction: AttractionOptions, - #[serde(with = "placement")] - placement: PlacementOptions, - #[serde(with = "RankingConfigDef")] - ranking: RankingConfig, - #[serde(with = "LodConfigDef")] - lod: LodConfig, -} diff --git a/libs/@local/graph/atlas/src/salt/fit/ingest.rs b/libs/@local/graph/atlas/src/salt/fit/ingest.rs index 39f329b985a..b118604325e 100644 --- a/libs/@local/graph/atlas/src/salt/fit/ingest.rs +++ b/libs/@local/graph/atlas/src/salt/fit/ingest.rs @@ -30,6 +30,7 @@ use super::{ use crate::{ dataset::{Dataset, DatasetOrigin, PROJECTOR_DIMENSIONS, TemporalAxes}, file::{ + ArtifactFile as _, array::{ArrayFile, ColumnScalar as _}, digest_file, generation::{Generation, ScratchDirectory, StagedGeneration}, diff --git a/libs/@local/graph/atlas/src/salt/fit/mod.rs b/libs/@local/graph/atlas/src/salt/fit/mod.rs index bf31e0bd44c..8a723ad6b8b 100644 --- a/libs/@local/graph/atlas/src/salt/fit/mod.rs +++ b/libs/@local/graph/atlas/src/salt/fit/mod.rs @@ -10,7 +10,7 @@ //! The last dataset touch splits the pipeline. [`ingest`] runs on the async runtime and drains the //! dataset's streams and the embedding provider into staged files. [`compute`] runs on the rayon //! pool behind [`offload`], and the CPU-heavy stages never occupy a tokio runtime thread. A stage -//! panic surfaces as `compute::ComputeError::Offload` instead of poisoning the executor. +//! panic surfaces as [`compute::ComputeError::Offload`] instead of poisoning the executor. //! //! # Memory discipline //! @@ -37,23 +37,29 @@ //! //! # Failure //! -//! Any stage error, failed admission check, or write failure aborts the run and publishes nothing. -//! The staging and scratch directories remove themselves, and a compute-side panic unwinds through -//! the worker that owns them, removing them the same way. A generation therefore exists exactly -//! when every stage and every check of one run passed. - -use core::{error::Error, fmt, num::NonZero}; +//! Publication is the seal's rename of the staging directory into the generation root. Any stage +//! error, failed admission check, or write failure before that rename aborts the run with nothing +//! published. The seal syncs the staged files and the staging directory before the rename and the +//! root after it, and an error after the rename (the root failing to open or to sync) returns a +//! [`FitError`] while the generation directory is already visible. Likewise, when a supplied +//! progress observer panics on the seal's completion report, the published run returns +//! [`compute::ComputeError::Offload`]. Success proves publication, while an error proves only +//! that the run did not complete. The staging and scratch directories attempt to remove +//! themselves when dropped, on the error return as on a compute-side unwind, and a removal failure +//! is logged rather than returned. + +use core::{error::Error, fmt, num::NonZero, panic::UnwindSafe}; use std::io::{self, Write as _}; use camino::Utf8Path; use rand::SeedableRng as _; use rand_xoshiro::Xoshiro256PlusPlus; -use self::prepare::norm; pub(crate) use self::{ - annotations::SuppliedAnnotations, echo::FitConfigDef, error::FitError, - verdicts::SuppliedVerdicts, + annotations::SuppliedAnnotations, error::FitError, verdicts::SuppliedVerdicts, }; +use self::{compute::ComputeError, prepare::norm}; +use super::projector::train::fit::TrainingScheduleOptions; use crate::{ dataset::Dataset, device::PhysicalDevice, @@ -67,6 +73,7 @@ use crate::{ AffinityCurve, NonNegative, Positive, non_negative, nz, positive, positive_unit_fraction, unit_fraction, }, + offload, progress::{self, Progress}, salt::{ embedding::CardEmbedder, @@ -96,7 +103,6 @@ use crate::{ pub(crate) mod annotations; mod compute; -mod echo; mod error; mod ingest; pub(crate) mod prepare; @@ -110,7 +116,7 @@ mod tests; /// The overrides supersede classifier predictions by precedence and must name relation types the /// edge stream carries: an override for a relation without edges contradicts the corpus and aborts /// the fit at resolution. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct PolicyOptions { /// Higher-precedence policy records superseding classifier predictions. pub overrides: Vec = Vec::new(), @@ -119,7 +125,7 @@ pub(crate) struct PolicyOptions { /// Training-set assembly over a supplied annotation corpus. pub assembly: AssemblyConfig = AssemblyConfig { .. }, /// The classifier fit over the assembled training set. - pub classifier_fit: ClassifierFitConfig = ClassifierFitConfig { .. }, + pub classifier_fit: ClassifierFitConfig = ClassifierFitConfig::default(), } const impl Default for PolicyOptions { @@ -136,9 +142,9 @@ const impl Default for PolicyOptions { /// the relation loss uses the same convention for its local scales. The unit weight is the neutral /// value because no evidence distinguishes landmark reliability yet. The per-anchor slot exists for /// the day it does. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct LandmarkSupport { - weight: f32 = 1.0, + pub weight: Positive = Positive::ONE, } const impl Default for LandmarkSupport { @@ -147,28 +153,6 @@ const impl Default for LandmarkSupport { } } -impl LandmarkSupport { - /// Validates a landmark support weight. - /// - /// Returns [`None`] unless the weight is finite and strictly positive. - #[must_use] - pub(crate) const fn new(weight: f32) -> Option { - if !(weight.is_finite() && weight > 0.0) { - return None; - } - Some(Self { weight }) - } - - /// Returns each anchor's mass in the support sum. - #[inline] - #[must_use] - pub(crate) const fn weight(self) -> f32 { - self.weight - } -} - -const _: () = assert!(LandmarkSupport::new(LandmarkSupport::default().weight()).is_some()); - /// Every setting of the projector placement. /// /// The model, its training run, and the condition ladder that publishes the canonical field. @@ -182,7 +166,7 @@ const _: () = assert!(LandmarkSupport::new(LandmarkSupport::default().weight()). /// bound, aborting the fit before training. /// /// [`affinity_offset`]: Self::affinity_offset -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct ProjectorOptions { /// The model shape. pub architecture: Architecture, @@ -249,17 +233,18 @@ impl ProjectorOptions { /// - 65536-row forward slices, the measured GPU sweet spot (on the CPU backend it means fewer, /// larger slices) #[must_use] - pub(crate) const fn ratified() -> Self { - Self { + pub(crate) const fn live() -> Self { + const LIVE: ProjectorOptions = ProjectorOptions { architecture: Architecture { .. }, - schedule: TrainingSchedule::new( - nz!(20_000), - 5_000, - nz!(250), - positive_unit_fraction!(1.0e-3), - unit_fraction!(1.0e-5), - ) - .expect("the ratified schedule is valid"), + schedule: TrainingSchedule::new(TrainingScheduleOptions { + steps: nz!(20_000), + boundary: 5_000, + refresh_interval: nz!(250), + initial_learning_rate: positive_unit_fraction!(1.0e-3), + minimum_learning_rate: unit_fraction!(1.0e-5), + }) + .ok() + .unwrap(), plan: BatchPlan { semantic_pairs: nz!(2048), ordinary_pairs: 2048, @@ -270,35 +255,43 @@ impl ProjectorOptions { temporal_anchors: 0, }, affinity_offset: positive!(1.0e-3), - support: SupportOptions::new(positive!(3.0), positive!(1.0e-3)), + support: SupportOptions { + threshold: positive!(3.0), + epsilon: positive!(1.0e-3), + }, budget: Budget { floor: positive!(2.0e-4), }, - coefficients: Coefficients::new( - Positive::ONE, - non_negative!(5.0), - NonNegative::ONE, - NonNegative::ONE, - NonNegative::ZERO, - NonNegative::ONE, - ), - miner: MinerOptions::new( - NonZero::new(8).expect("the ratified quota is nonzero"), - NonZero::new(3).expect("the ratified margin is nonzero"), - Positive::ONE, - Positive::ONE, - ), - lens: RelationLens::new( - CoincidentEnergy::new(non_negative!(0.05), positive!(1.0)), - positive!(0.25), - positive!(1.0e-3), - ), + coefficients: Coefficients { + semantic: Positive::ONE, + ordinary: non_negative!(5.0), + hard: NonNegative::ONE, + relation: NonNegative::ONE, + anchor: NonNegative::ZERO, + landmark: NonNegative::ONE, + }, + miner: MinerOptions { + neighbours: nz!(8), + search_margin: nz!(3), + maximum_weight: Positive::ONE, + rank_exponent: Positive::ONE, + }, + lens: RelationLens { + coincident: CoincidentEnergy { + radius: non_negative!(0.05), + threshold: positive!(1.0), + }, + temperature: positive!(0.25), + epsilon: positive!(1.0e-3), + }, protection: ProtectionConfig::default(), landmark_support: LandmarkSupport { .. }, - forward_rows: NonZero::new(1 << 16).expect("the ratified slice is nonzero"), + forward_rows: nz!(1 << 16), ladder: LadderOptions { .. }, vacuous: false, - } + }; + + LIVE } } @@ -314,7 +307,8 @@ impl ProjectorOptions { reason = "the projector default must be a const expression, which a boxed variant cannot \ produce; the asymmetry costs one embedded options struct per configuration value" )] -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] pub(crate) enum PlacementOptions { /// Every row takes its assigned landmark's layout coordinate. /// @@ -332,7 +326,7 @@ pub(crate) enum PlacementOptions { /// search structure. Either construction answers to the same recall spot check, and neither /// outlives the fit: the wrapper's index lives in the fit's scratch directory, which removes /// itself when the run ends. -#[derive(Debug, Copy, Clone, PartialEq, Default)] +#[derive(Debug, Copy, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] pub(crate) enum KnnConstructionChoice { /// Construct through the HNSW backend pinned by [`FitConfig::index`]. #[default] @@ -345,7 +339,7 @@ pub(crate) enum KnnConstructionChoice { /// /// Stage options keep their own documented defaults. The fields without defaults are the choices no /// fit can imply, which are the seed, the landmark capacity, and the low-dimensional kernel. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct FitConfig { /// The fit's seed. /// @@ -379,7 +373,7 @@ pub(crate) struct FitConfig { /// Shared attraction weighting and force pruning. pub attraction: AttractionOptions = AttractionOptions::default(), /// How the fit produces the canonical coordinates. - pub placement: PlacementOptions = PlacementOptions::Projector(ProjectorOptions::ratified()), + pub placement: PlacementOptions = PlacementOptions::Projector(ProjectorOptions::live()), /// The importance signal behind the delivery ranking. pub ranking: RankingConfig = RankingConfig::default(), /// The level-of-detail schedule. @@ -561,8 +555,8 @@ pub(crate) struct Supplies<'fit> { /// visible. #[expect( clippy::significant_drop_tightening, - reason = "the staging and scratch directories move into the compute closure whole; nothing \ - here can drop them earlier" + reason = "the staging and scratch directories move into the compute closure whole and are \ + dropped inside it" )] pub(crate) async fn fit( dataset: &D, @@ -580,7 +574,7 @@ pub(crate) async fn fit( where D: Dataset, E: CardEmbedder + Sync, - P: Progress + Sync, + P: Progress + Sync, { let staging = root.stage()?; let scratch = root.scratch()?; @@ -668,48 +662,9 @@ where // half rather than a borrow the spawn cannot hold. let detached = progress.detach(); let published = - offload(move || compute.run::(&detached)).await?; + offload::run(move || compute.run::(&detached)) + .await + .map_err(ComputeError::from)??; Ok(published) } - -/// Runs compute-side work on the rayon pool, keeping the tokio runtime thread free. -/// -/// The caller's span carries across, so stage spans keep their parent. A panic in the work unwinds -/// the worker and surfaces as [`compute::ComputeError::Panicked`]. The unwind drops the staging and -/// scratch directories the worker owns, and they remove themselves. The async executor never -/// observes the unwind. -async fn offload( - work: impl FnOnce() -> Result + Send + 'static, -) -> Result { - let span = tracing::Span::current(); - let (sender, receiver) = tokio::sync::oneshot::channel(); - - rayon::spawn(move || { - let _entered = span.entered(); - // The work owns everything it touches, and the unwind drops every capture, so no shared - // state survives to observe a broken invariant. - let result = std::panic::catch_unwind(core::panic::AssertUnwindSafe(work)).unwrap_or_else( - |payload| { - Err(compute::ComputeError::Panicked { - message: panic_message(payload.as_ref()), - }) - }, - ); - // A send failure means the fit future dropped its receiver, so the result has no recipient. - let _: Result<(), _> = sender.send(result); - }); - - receiver - .await - .expect("the worker owns the sender and always sends") -} - -/// Extracts the conventional string payloads of a panic. -fn panic_message(payload: &(dyn core::any::Any + Send)) -> Option { - if let Some(message) = payload.downcast_ref::<&'static str>() { - return Some((*message).to_owned()); - } - - payload.downcast_ref::().cloned() -} diff --git a/libs/@local/graph/atlas/src/salt/fit/prepare/identity.rs b/libs/@local/graph/atlas/src/salt/fit/prepare/identity.rs index 3a9b080598f..fbdf44b3eea 100644 --- a/libs/@local/graph/atlas/src/salt/fit/prepare/identity.rs +++ b/libs/@local/graph/atlas/src/salt/fit/prepare/identity.rs @@ -23,15 +23,21 @@ //! [`Dataset::EdgeId`]: crate::dataset::Dataset::EdgeId use core::{error::Error, fmt, marker::PhantomData}; -use std::io; +use std::{io, path::Path}; use fst::Streamer as _; use hashql_core::id::{IdSlice, IdVec}; use zerocopy::{FromBytes as _, TryFromBytes as _}; +use super::IdentityProvider; use crate::{ - file::identity::{ - Key, KeyKind, Kind, PayloadSpan, Row, read::IdentityFile, write::write_regions, + file::{ + ArtifactFile, + identity::{ + Key, KeyKind, Kind, PayloadSpan, Row, + read::{IdentityFile, OpenIdentityError}, + write::write_regions, + }, }, integrity::{Sha256, Sha256Digest, Writer}, }; @@ -104,7 +110,7 @@ where /// A structurally valid identity file whose contents violate the table's domain invariants. #[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum InvalidIdentityFile { +pub(crate) enum InvalidIdentityFile { /// The file covers a different row domain. Domain { expected: Kind, actual: Kind }, /// The file's key kind is not the id type's. @@ -166,7 +172,51 @@ impl fmt::Display for InvalidIdentityFile { impl Error for InvalidIdentityFile {} -/// A written identity table reopened as its mapped lookup surface. +/// Opening a written identity table as its typed lookup surface failed. +#[derive(Debug)] +pub(crate) enum OpenIdentityTableArchiveError { + /// The identity file failed to open. + Open(OpenIdentityError), + /// The file violates the table's domain invariants. + Invalid(InvalidIdentityFile), +} + +const impl From for OpenIdentityTableArchiveError { + fn from(error: InvalidIdentityFile) -> Self { + Self::Invalid(error) + } +} + +const impl From for OpenIdentityTableArchiveError { + fn from(error: OpenIdentityError) -> Self { + Self::Open(error) + } +} + +impl fmt::Display for OpenIdentityTableArchiveError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Open(error) => write!(fmt, "the identity file failed to open: {error}"), + Self::Invalid(error) => { + write!( + fmt, + "the identity file violates the table's domain invariants: {error}" + ) + } + } + } +} + +impl Error for OpenIdentityTableArchiveError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Open(error) => Some(error), + Self::Invalid(error) => Some(error), + } + } +} + +/// A validated, memory-mapped mapping between source identities and rows. /// /// Construction validates the identity bijection and payload spans. The source identity type is /// `K`, and `R` is the row domain. @@ -177,6 +227,22 @@ pub(crate) struct IdentityTableArchive { row: PhantomData, } +impl ArtifactFile for IdentityTableArchive +where + K: Key, + R: Row, +{ + type Error = OpenIdentityTableArchiveError; + + fn open(path: impl AsRef) -> Result + where + Self: Sized, + { + let file = IdentityFile::open(path)?; + Self::new(file).map_err(From::from) + } +} + impl IdentityTableArchive where K: Key, @@ -211,7 +277,7 @@ where row: PhantomData, }; - let ids = table.ids().as_raw(); + let ids = table.keys().as_raw(); let index = table.file.index(); if index.len() as u64 != table.file.rows() { return Err(InvalidIdentityFile::IndexSize { @@ -256,7 +322,7 @@ where /// Views the id column, in row order. #[must_use] - pub(crate) fn ids(&self) -> &IdSlice { + pub(crate) fn keys(&self) -> &IdSlice { IdSlice::from_raw( <[K]>::ref_from_bytes(self.file.keys()) .expect("open validated the key kind and `K` is unaligned"), @@ -275,24 +341,50 @@ where self.file.rows() } - /// Returns the id of `row`, or [`None`] beyond the domain. - #[must_use] - pub(crate) fn id(&self, row: R) -> Option { - self.ids().get(row).copied() + /// Iterates the rows carrying a non-empty display payload, in row order. + pub(crate) fn displayed_rows(&self) -> impl Iterator { + self.spans() + .iter_enumerated() + .filter(|&(_, span)| span.length() > 0) + .map(|(row, _)| row) } +} - /// Returns the row carrying `id`, or [`None`] when no row does. - #[must_use] - pub(crate) fn row_of(&self, id: K) -> Option { - self.file.index().get(id.as_bytes()).map(R::from_u64) +impl IdentityProvider for IdentityTableArchive +where + K: Key, + R: Row, +{ + #[expect( + clippy::cast_possible_truncation, + reason = "`Self::new` bounded every span by the payload region, whose length is a `usize`" + )] + #[inline] + fn count(&self) -> usize { + self.len() as usize + } + + #[inline] + fn key_of(&self, row: R) -> Option { + self.keys().get(row).copied() + } + + #[inline] + fn row_of(&self, key: K) -> Option { + self.file.index().get(key.as_bytes()).map(R::from_u64) + } + + #[inline] + fn payload_of_key(&self, key: K) -> Option<&::Payload> { + let key = self.row_of(key)?; + self.payload_of_row(key) } #[expect( clippy::cast_possible_truncation, reason = "`Self::new` bounded every span by the payload region, whose length is a `usize`" )] - #[must_use] - pub(crate) fn payload_of(&self, row: R) -> Option<&K::Payload> { + fn payload_of_row(&self, row: R) -> Option<&::Payload> { let span = self.spans().get(row)?; let offset = span.offset() as usize; let length = span.length() as usize; @@ -303,14 +395,6 @@ where // Therefore the bytes validated there are the bytes sliced here. Some(unsafe { ::try_ref_from_bytes(bytes).unwrap_unchecked() }) } - - /// Iterates the rows carrying a non-empty display payload, in row order. - pub(crate) fn displayed_rows(&self) -> impl Iterator { - self.spans() - .iter_enumerated() - .filter(|&(_, span)| span.length() > 0) - .map(|(row, _)| row) - } } #[cfg(test)] @@ -329,12 +413,14 @@ mod tests { memory::{MemoryNodeId, MemoryOntologyId}, }, file::{ + ArtifactFile as _, identity::{FileHeader, KeyKind, Kind, PaddedFileHeader, read::IdentityFile}, region::write_region, }, identity::{NodeRowId, OntologyRowId}, integrity::Sha256Digest, postgres::id::ArchivedOntologyTypeUuid, + salt::fit::prepare::IdentityProvider as _, }; /// A per-test scratch file path under the system temp directory. @@ -388,7 +474,7 @@ mod tests { /// `row_of` with misses answering `None`, and `payload_of_row` slices the interned region /// including the empty label. #[test] - fn written_table_reopens_with_all_three_translations() { + fn lookup_translations() { let (path, digest) = written_fixture("roundtrip.idnt"); // The digest is the digest of the written bytes. @@ -405,31 +491,31 @@ mod tests { // row → id → row round-trips for every row, and misses answer `None`. for (position, id) in IDS.iter().enumerate() { let row = NodeRowId::new(position as u64); - assert_eq!(table.id(row), Some(*id)); + assert_eq!(table.key_of(row), Some(*id)); assert_eq!(table.row_of(*id), Some(row)); } - assert_eq!(table.id(NodeRowId::new(3)), None); + assert_eq!(table.key_of(NodeRowId::new(3)), None); assert_eq!(table.row_of(MemoryNodeId::new(0)), None); // row → payload slices the interned region, the empty label included. assert_eq!( - table.payload_of(NodeRowId::new(0)), + table.payload_of_row(NodeRowId::new(0)), Some(legend("beta").as_ref()) ); assert_eq!( - table.payload_of(NodeRowId::new(1)), + table.payload_of_row(NodeRowId::new(1)), Some(legend("alpha").as_ref()) ); assert_eq!( - table.payload_of(NodeRowId::new(2)), + table.payload_of_row(NodeRowId::new(2)), Some(legend("").as_ref()) ); - assert_eq!(table.payload_of(NodeRowId::new(3)), None); + assert_eq!(table.payload_of_row(NodeRowId::new(3)), None); } /// An empty table writes, reopens with zero rows, and answers `None` for any lookup. #[test] - fn empty_table_round_trips() { + fn roundtrip_empty() { let table = IdentityTable::::new(); let mut bytes = Vec::new(); let _digest = table @@ -450,7 +536,7 @@ mod tests { /// Writing a table in which two rows carry one key panics with the documented message. #[test] #[should_panic(expected = "two rows carry one key")] - fn table_refuses_duplicate_ids_at_write() { + fn write_duplicate_ids() { let mut table = IdentityTable::::new(); table.push(MemoryNodeId::new(7)); table.push(MemoryNodeId::new(7)); @@ -464,8 +550,8 @@ mod tests { /// Six hundred ids whose byte order differs from their value order write and look up correctly /// in both directions, with a miss beyond the domain answering `None`. #[test] - fn lookups_hold_at_six_hundred_rows() { - // Little-endian bytes of 0..600 sort unlike the values, so the write path's ordering + fn lookups_nonmonotone_large_domain() { + // Little-endian bytes of 0..600 sort unlike the values. The write path's ordering // and the index lookups both face a non-monotone id column. let mut table = IdentityTable::::new(); for id in 0..600_u64 { @@ -491,14 +577,17 @@ mod tests { Some(NodeRowId::new(row)), "row {row}" ); - assert_eq!(table.id(NodeRowId::new(row)), Some(MemoryNodeId::new(row))); + assert_eq!( + table.key_of(NodeRowId::new(row)), + Some(MemoryNodeId::new(row)) + ); } assert_eq!(table.row_of(MemoryNodeId::new(600)), None); } /// Opening a node table as an ontology table fails with `Domain` naming both kinds. #[test] - fn archive_refuses_a_foreign_row_domain() { + fn archive_foreign_row_domain() { let (path, _digest) = written_fixture("foreign-domain.idnt"); assert_matches!( @@ -514,7 +603,7 @@ mod tests { /// Opening a `u64` keyed table under a UUID key type fails with `KeyKind` naming both kinds. #[test] - fn archive_refuses_a_foreign_id_type() { + fn archive_foreign_id_type() { let (path, _digest) = written_fixture("foreign-id.idnt"); assert_matches!( @@ -533,7 +622,7 @@ mod tests { /// Moving a byte of the id column under an intact index fails the open with /// `ColumnDisagreement` naming the row. #[test] - fn archive_refuses_a_disagreeing_id_column() { + fn archive_disagreeing_id_column() { let (path, _digest) = written_fixture("disagreeing-column.idnt"); // Row 0's first column byte moves under the index: the file stays structurally valid @@ -555,7 +644,7 @@ mod tests { /// A span whose length overreaches the payload region fails the open with `SpanOutOfBounds` /// naming the row. #[test] - fn archive_refuses_a_span_beyond_the_payload() { + fn archive_span_out_of_bounds() { let (path, _digest) = written_fixture("overreaching-span.idnt"); // Three eight-byte ids pad to one region unit each for column and index. The span @@ -574,7 +663,7 @@ mod tests { /// A label byte no UTF-8 sequence contains fails the typed open with `Payload` naming the row. #[test] - fn archive_refuses_a_payload_that_is_not_utf8() { + fn archive_invalid_utf8_payload() { let (path, _digest) = written_fixture("invalid-payload.idnt"); // The payload region starts at 0x4000 and row 0's span selects its first twelve bytes: @@ -597,7 +686,7 @@ mod tests { /// A hand-built file whose index holds fewer entries than its rows fails the typed open with /// `IndexSize` carrying both counts. #[test] - fn archive_refuses_an_index_missing_a_row() { + fn archive_missing_index_row() { // Hand-crafted geometry the writer refuses to produce: two rows whose index carries one // entry. The format accepts it, and the typed open is what refuses. let keys = [U64::::new(1), U64::::new(2)]; diff --git a/libs/@local/graph/atlas/src/salt/fit/prepare/mod.rs b/libs/@local/graph/atlas/src/salt/fit/prepare/mod.rs index 7a0e1e23b1b..6595fee107e 100644 --- a/libs/@local/graph/atlas/src/salt/fit/prepare/mod.rs +++ b/libs/@local/graph/atlas/src/salt/fit/prepare/mod.rs @@ -28,9 +28,12 @@ pub(crate) mod identity; pub(crate) mod instance; pub(crate) mod norm; +mod provider; #[cfg(test)] mod tests; +pub(crate) use self::provider::IdentityProvider; + /// Writing the node representation matrix failed. #[derive(Debug)] pub(crate) enum PrepareError { diff --git a/libs/@local/graph/atlas/src/salt/fit/prepare/norm.rs b/libs/@local/graph/atlas/src/salt/fit/prepare/norm.rs index 170c95b5623..517d29c81e9 100644 --- a/libs/@local/graph/atlas/src/salt/fit/prepare/norm.rs +++ b/libs/@local/graph/atlas/src/salt/fit/prepare/norm.rs @@ -50,7 +50,7 @@ const _: () = assert!( ); /// Pinned tolerance and sampling settings for one norm spot check. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct SpotCheckOptions { /// Admitted deviation of a row's squared norm from one, two-sided. pub tolerance: DPositive = DEFAULT_TOLERANCE, diff --git a/libs/@local/graph/atlas/src/salt/fit/prepare/provider.rs b/libs/@local/graph/atlas/src/salt/fit/prepare/provider.rs new file mode 100644 index 00000000000..8edaab6d3cf --- /dev/null +++ b/libs/@local/graph/atlas/src/salt/fit/prepare/provider.rs @@ -0,0 +1,70 @@ +//! The read contract over one row domain's identity table. + +use crate::file::identity::{Key, Row}; + +/// A two-way row-to-source-key map with each key's display payload. +/// +/// [`count`](Self::count) bounds the row domain. A lookup answers [`None`] for a row at or beyond +/// that bound. It also answers [`None`] when the provider holds no row for a given key, or no key +/// for a given row. +pub(crate) trait IdentityProvider +where + R: Row, + K: Key, +{ + /// Returns the number of rows in the domain. + fn count(&self) -> usize; + + /// Returns the key assigned to `row`, or [`None`] when the provider holds no key for it. + fn key_of(&self, row: R) -> Option; + + /// Returns the row `key` was assigned, or [`None`] when the domain holds no such key. + fn row_of(&self, key: K) -> Option; + + /// Returns the display payload stored for `key`. + /// + /// [`None`] when the domain holds no such key. + fn payload_of_key(&self, key: K) -> Option<&K::Payload>; + + /// Returns the display payload stored for `row`. + /// + /// [`None`] when the provider holds no key for it. + /// + /// The default resolves the row's key and then its payload. + #[inline] + fn payload_of_row(&self, row: R) -> Option<&K::Payload> { + let key = self.key_of(row)?; + self.payload_of_key(key) + } +} + +impl + ?Sized> IdentityProvider for &T +where + R: Row, + K: Key, +{ + #[inline] + fn count(&self) -> usize { + T::count(self) + } + + #[inline] + fn key_of(&self, row: R) -> Option { + T::key_of(self, row) + } + + #[inline] + fn row_of(&self, key: K) -> Option { + T::row_of(self, key) + } + + #[inline] + fn payload_of_key(&self, key: K) -> Option<&K::Payload> { + T::payload_of_key(self, key) + } + + #[inline] + fn payload_of_row(&self, row: R) -> Option<&K::Payload> { + T::payload_of_row(self, row) + } +} diff --git a/libs/@local/graph/atlas/src/salt/fit/tests.rs b/libs/@local/graph/atlas/src/salt/fit/tests.rs index 54e9b1bea9f..4c5fb0d99ba 100644 --- a/libs/@local/graph/atlas/src/salt/fit/tests.rs +++ b/libs/@local/graph/atlas/src/salt/fit/tests.rs @@ -1,5 +1,5 @@ use alloc::borrow::Cow; -use core::{future::ready, num::NonZero}; +use core::{assert_matches, future::ready, num::NonZero}; use std::{collections::HashMap, fs}; use camino::{Utf8Path, Utf8PathBuf}; @@ -26,6 +26,7 @@ use crate::{ }, device::Device, file::{ + ArtifactFile as _, array::ArrayFile, attraction::read::AttractionFile, classifier::read::ClassifierFile, @@ -46,14 +47,15 @@ use crate::{ integrity::{Sha256, Update as _}, math::{ AffinityCurve, AlignedVecN, BoxedVecN, Positive, Similarity, UnitFraction, Vec2, VecN, - d_non_negative, d_positive, greater_than_one, non_negative, open_unit_fraction, positive, - positive_unit_fraction, unit_fraction, + d_non_negative, d_positive, greater_than_one, non_negative, nz, open_unit_fraction, + positive, positive_unit_fraction, unit_fraction, }, postgres::id::ArchivedOntologyTypeUuid, progress::NoProgress, salt::{ adjacency::{AdjacencyArchive, EdgeList}, embedding::{CardEmbedder, EmbedderFingerprint}, + fit::prepare::IdentityProvider as _, knn::{artifact::KnnArchive, recall::RecallAdmission, table::KnnView}, ladder::{ CanonicalError, @@ -67,15 +69,18 @@ use crate::{ GeometryClass, PolicyOverride, PolicySource, Posterior, artifact::PolicyTableArchive, classifier::{ - Classifier, FitConfig as ClassifierFitConfig, PreparationSettings, SolverConfig, - TrainingRow, TrainingSet, fit as fit_classifier, + Classifier, FitConfig as ClassifierFitConfig, FitOptions as ClassifierFitOptions, + PreparationSettings, SolverOptions, TrainingRow, TrainingSet, + fit as fit_classifier, }, }, - postings::artifact::PostingsArchive, + postings::artifact::{Membership, PostingsArchive}, projector::{ loss::CoincidentEnergy, model::Architecture, - train::{BatchPlan, RelationLens, TrainError, TrainingSchedule}, + train::{ + BatchPlan, RelationLens, TrainError, TrainingSchedule, fit::TrainingScheduleOptions, + }, verdict::{PlacementClass, ReviewedVerdicts}, }, relation::{ @@ -253,16 +258,13 @@ impl CardEmbedder for HashEmbedder { /// The classifier fit echo round-trips every solver knob. #[test] fn classifier_fit_echo_round_trips_every_knob() { - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct Echo(#[serde(with = "super::FitConfigDef")] FitConfig); - - // Every knob differs from its default and from every sibling, so the round-trip equality - // certifies each field's wire path individually. - let distinct = ClassifierFitConfig { - solver: SolverConfig { + // Every knob differs from its default and from every sibling: the round-trip equality + // therefore certifies each field's wire path individually. + let distinct = ClassifierFitConfig::new(ClassifierFitOptions { + solver: SolverOptions { preparation: PreparationSettings { regularization: d_positive!(0.625), - target_sum_tolerance_ulps: NonZero::new(24).expect("twenty-four is nonzero"), + target_sum_tolerance_ulps: nz!(24), curvature_relative_floor: d_positive!(1.0e-11), }, radius_minimum: d_positive!(3.0e-8), @@ -274,20 +276,21 @@ fn classifier_fit_echo_round_trips_every_knob() { eta_expand: open_unit_fraction!(0.8), relative_scaled_gradient_tolerance: open_unit_fraction!(2.0e-6), absolute_scaled_gradient_tolerance: d_non_negative!(1.0e-9), - objective_resolution_ulps: NonZero::new(5).expect("five is nonzero"), - curvature_guard_ulps: NonZero::new(17).expect("seventeen is nonzero"), - maximum_outer_iterations: NonZero::new(501).expect("the budget is nonzero"), + objective_resolution_ulps: nz!(5), + curvature_guard_ulps: nz!(17), + maximum_outer_iterations: nz!(501), }, folds: 3, seed: 11, - }; + }) + .expect("the distinct classifier fit config is valid"); let mut config = config(); config.policy.classifier_fit = distinct; - let document = serde_json::to_value(Echo(config.clone())).expect("the echo serializes"); - let echoed: Echo = serde_json::from_value(document).expect("the echo deserializes"); - assert_eq!(echoed.0, config); + let document = serde_json::to_value(config.clone()).expect("the echo serializes"); + let echoed: FitConfig = serde_json::from_value(document).expect("the echo deserializes"); + assert_eq!(echoed, config); } /// An echo carrying the retired solver knob names decodes. @@ -301,10 +304,7 @@ fn classifier_fit_echo_round_trips_every_knob() { /// consecutive-rejection budget, and no work limit besides the outer-iteration cap. #[test] fn config_echo_decodes_the_retired_solver_knobs() { - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct Echo(#[serde(with = "super::FitConfigDef")] FitConfig); - - let mut document = serde_json::to_value(Echo(config())).expect("the echo serializes"); + let mut document = serde_json::to_value(config()).expect("the echo serializes"); let solver = document .pointer_mut("/policy/classifier_fit/solver") .and_then(serde_json::Value::as_object_mut) @@ -332,17 +332,14 @@ fn config_echo_decodes_the_retired_solver_knobs() { serde_json::json!(500_000), ); - let echoed: Echo = + let echoed: FitConfig = serde_json::from_value(document).expect("the echo decodes with unknown solver fields"); - assert_eq!(echoed.0, config()); + assert_eq!(echoed, config()); } /// A config echo names every setting, and a missing setting fails the parse. #[test] fn config_echo_requires_every_setting() { - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct Echo(#[serde(with = "super::FitConfigDef")] FitConfig); - for path in [ "/selection/parallel_chunk", "/policy/assembly/maximum_group_fraction", @@ -350,16 +347,19 @@ fn config_echo_requires_every_setting() { "/policy/classifier_fit", "/construction", ] { - let mut document = serde_json::to_value(Echo(config())).expect("the echo serializes"); + let mut document = serde_json::to_value(config()).expect("the echo serializes"); let (parent, field) = path.rsplit_once('/').expect("every path names a field"); let object = document .pointer_mut(parent) .and_then(serde_json::Value::as_object_mut) .expect("the echo carries the field's parent object"); - assert!(object.remove(field).is_some(), "{path} rides the echo"); + assert!( + object.remove(field).is_some(), + "{path} is present in the echo" + ); assert!( - serde_json::from_value::(document).is_err(), + serde_json::from_value::(document).is_err(), "an echo without {path} does not parse" ); } @@ -367,49 +367,72 @@ fn config_echo_requires_every_setting() { /// Preserves a non-default diagnostic floor in the projector configuration. #[test] -fn budget_echo_writes_the_floor_and_decodes_the_retired_clamp_array() { - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct Echo(#[serde(with = "super::FitConfigDef")] FitConfig); - +fn budget_echo_projector() { + let mut options = projector_options(); + options.budget.floor = positive!(0.125); let config = FitConfig { - placement: PlacementOptions::Projector(projector_options()), + placement: PlacementOptions::Projector(options), ..config() }; - let document = serde_json::to_value(Echo(config.clone())).expect("the echo serializes"); + let document = serde_json::to_value(config.clone()).expect("the echo should serialize"); assert_eq!( document .pointer("/placement/projector/budget") - .expect("the echo carries the budget"), - &serde_json::json!({ "floor": 2.0e-4_f32 }), - "the budget echoes as the bare floor object", + .expect("the echo should contain the budget"), + &serde_json::json!({ "floor": 0.125_f32 }), + "the budget should encode the configured floor as an object", ); - let echoed: Echo = serde_json::from_value(document.clone()).expect("the echo deserializes"); - assert_eq!(echoed.0, config); - - let mut document = document; - *document - .pointer_mut("/placement/projector/budget") - .expect("the echo carries the budget") = - serde_json::json!([0.1_f32, 0.1_f32, 2.0e-4_f32, 1.0e-12_f32]); - let echoed: Echo = serde_json::from_value(document).expect("the retired clamp form decodes"); - assert_eq!(echoed.0, config); + let echoed: FitConfig = serde_json::from_value(document).expect("the echo should deserialize"); + assert_eq!(echoed, config, "the echo should preserve the configuration"); } /// A config echo revalidates the group budget's domain. #[test] fn config_echo_validates_the_group_budget() { - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct Echo(#[serde(with = "super::FitConfigDef")] FitConfig); - - let mut document = serde_json::to_value(Echo(config())).expect("the echo serializes"); + let mut document = serde_json::to_value(config()).expect("the echo serializes"); document .pointer_mut("/policy/assembly") .and_then(serde_json::Value::as_object_mut) .expect("the echo carries the assembly settings") .insert("maximum_group_fraction".to_owned(), serde_json::json!(1.5)); - let error = serde_json::from_value::(document) + let error = serde_json::from_value::(document) .expect_err("an out-of-range budget refuses to parse"); - assert!(error.to_string().contains("fraction in (0, 1]")); + assert!( + error.to_string().contains("half-open unit interval"), + "should reject the group fraction's domain: {error}" + ); +} + +#[test] +fn config_echo_validates_the_classifier_fit() { + for (path, value, message) in [ + ( + "/policy/classifier_fit/solver/radius_minimum", + serde_json::json!(2.0), + "trust radii must satisfy", + ), + ( + "/policy/classifier_fit/solver/eta_accept", + serde_json::json!(0.75), + "acceptance thresholds must satisfy", + ), + ( + "/policy/classifier_fit/folds", + serde_json::json!(1), + "cannot hold anything out", + ), + ] { + let mut document = serde_json::to_value(config()).expect("the echo serializes"); + *document + .pointer_mut(path) + .expect("the echo should contain the field") = value; + let error = serde_json::from_value::(document) + .expect_err("the cross-field violation refuses to parse"); + assert!( + error.to_string().contains(message), + "the error should name {path}: {error}", + ); + } } /// Builds a landmark-baseline configuration that skips projector training. @@ -422,7 +445,7 @@ fn config() -> FitConfig { }, curve: AffinityCurve::fit(positive!(1.0), positive!(0.1)) .expect("the reference falloff is well-conditioned"), - neighbours: NonZero::new(4).expect("the fixture neighbour count is nonzero"), + neighbours: nz!(4), // The fixtures whose subject is not the placement opt out of // the default's training run; the projector tests configure // their own schedules. @@ -474,9 +497,14 @@ fn fixture_classifier() -> Classifier { .collect(); let training = TrainingSet::new(embeddings, &rows).expect("the fixture corpus validates"); - fit_classifier(training, ClassifierFitConfig { folds: 2, .. }, &NoProgress) - .expect("the fixture classifier fits") - .classifier + fit_classifier( + training, + ClassifierFitConfig::new(ClassifierFitOptions { folds: 2, .. }) + .expect("the fixture classifier fit config is valid"), + &NoProgress, + ) + .expect("the fixture classifier fits") + .classifier } /// Wraps a fitted model as the fit's supplied classifier input. @@ -644,7 +672,7 @@ fn assert_rows_sit_on_landmarks(published: &Utf8Path) { point.x().to_bits() == landmark.x().to_bits() && point.y().to_bits() == landmark.y().to_bits() }), - "every row should sit exactly on its assigned landmark", + "every row should lie exactly on its assigned landmark", ); } @@ -662,7 +690,7 @@ fn assert_identities_translate(published: &Utf8Path) { assert_eq!(nodes.len(), NODES as u64); for row in 0..NODES as u64 { assert_eq!( - nodes.id(NodeRowId::new(row)), + nodes.key_of(NodeRowId::new(row)), Some(MemoryNodeId::new(row)), "row {row}" ); @@ -676,7 +704,7 @@ fn assert_identities_translate(published: &Utf8Path) { Label::new(&format!("node {row}")), ); assert_eq!( - nodes.payload_of(NodeRowId::new(row)), + nodes.payload_of_row(NodeRowId::new(row)), Some(&*legend), "payload of row {row}" ); @@ -689,15 +717,24 @@ fn assert_identities_translate(published: &Utf8Path) { ) .expect("the edge identities should validate"); assert_eq!(edge_ids.len(), 2); - assert_eq!(edge_ids.id(EdgeRowId::new(0)), Some(MemoryEdgeId::new(100))); + assert_eq!( + edge_ids.key_of(EdgeRowId::new(0)), + Some(MemoryEdgeId::new(100)) + ); assert_eq!( edge_ids.row_of(MemoryEdgeId::new(101)), Some(EdgeRowId::new(1)) ); let employs_100 = OwnedLegend::new(OntologyRowId::new(2), Label::new("employs 100")); - assert_eq!(edge_ids.payload_of(EdgeRowId::new(0)), Some(&*employs_100)); + assert_eq!( + edge_ids.payload_of_row(EdgeRowId::new(0)), + Some(&*employs_100) + ); let employs_101 = OwnedLegend::new(OntologyRowId::new(2), Label::new("employs 101")); - assert_eq!(edge_ids.payload_of(EdgeRowId::new(1)), Some(&*employs_101)); + assert_eq!( + edge_ids.payload_of_row(EdgeRowId::new(1)), + Some(&*employs_101) + ); let ontology_ids = IdentityTableArchive::::new( IdentityFile::open(published.join("ontology-identities.idnt")) @@ -708,7 +745,7 @@ fn assert_identities_translate(published: &Utf8Path) { for (row, icon) in ["person", "company", "\u{3bb}"].into_iter().enumerate() { let expected = OwnedIcon::from(icon); assert_eq!( - ontology_ids.payload_of(OntologyRowId::new(row as u64)), + ontology_ids.payload_of_row(OntologyRowId::new(row as u64)), Some(&*expected), "ontology row {row}" ); @@ -746,16 +783,12 @@ fn assert_postings_read_back(published: &Utf8Path, repository: &SaltRepository) "position {position} should carry row {row}'s direct type", ); } - let domain = BasePosition::from_usize(0) - ..BasePosition::from_usize( - usize::try_from(postings.points()).expect("the point count fits usize"), - ); - let members = |type_row: u64| { - postings - .membership(OntologyRowId::new(type_row)) - .expect("the fixture types lie in the type domain") - .positions_in(domain.clone()) - .count() as u64 + let members = |type_row: u64| match postings + .membership(OntologyRowId::new(type_row)) + .expect("the fixture types lie in the type domain") + { + Membership::List(positions) => positions.len() as u64, + Membership::Dense(set) => set.count(), }; assert_eq!(members(0), members(1), "rows alternate the node types"); assert_eq!(members(0) + members(1), NODES as u64); @@ -831,7 +864,7 @@ async fn policy_artifacts_publish_and_read_back() { assert_eq!( policy.strength.to_bits(), 1.0_f32.to_bits(), - "the strength head is disabled", + "the strength head is off", ); } @@ -1117,7 +1150,8 @@ async fn annotation_corpus_fits_and_stages_the_classifier() { .expect("a contract-conforming corpus admits"); let mut config = config(); - config.policy.classifier_fit = ClassifierFitConfig { folds: 2, .. }; + config.policy.classifier_fit = ClassifierFitConfig::new(ClassifierFitOptions { folds: 2, .. }) + .expect("the fixture classifier fit config is valid"); let published = fit( &dataset, @@ -1313,8 +1347,12 @@ async fn override_supersedes_the_classifier() { overrides: vec![PolicyOverride { relation: OntologyRowId::new(2), source: PolicySource::Human, - distribution: Posterior::new([0.25, 0.5, 0.25]) - .expect("the asserted distribution sums to one"), + distribution: Posterior::new([ + unit_fraction!(0.25), + unit_fraction!(0.5), + unit_fraction!(0.25), + ]) + .expect("the asserted distribution sums to one"), }], .. }, @@ -1452,13 +1490,8 @@ async fn defective_corpus_publishes_nothing() { &NoProgress, ) .await; - assert!( - matches!( - result, - Err(FitError::RepresentationDefects(ref check)) if !check.passes(), - ), - "the defective corpus should fail the norm check", - ); + assert_matches!(result, + Err(FitError::RepresentationDefects(ref check)) if !check.passes(), "the defective corpus should fail the norm check"); // Failure leaves the root empty: the fit clears its transients and publishes no generation. let entries: Vec<_> = fs::read_dir(&path) @@ -1476,13 +1509,13 @@ async fn defective_corpus_publishes_nothing() { /// For orchestration certificates whose asserted behaviour does not depend on trained movement: a /// vacuous or forceless run still reaches the boundary at the minimum cost a valid schedule allows. fn minimal_schedule() -> TrainingSchedule { - TrainingSchedule::new( - NonZero::new(1).expect("the fixture step count is nonzero"), - 0, - NonZero::new(1).expect("the fixture cadence is nonzero"), - positive_unit_fraction!(1.0e-3), - unit_fraction!(1.0e-5), - ) + TrainingSchedule::new(TrainingScheduleOptions { + steps: nz!(1), + boundary: 0, + refresh_interval: nz!(1), + initial_learning_rate: positive_unit_fraction!(1.0e-3), + minimum_learning_rate: unit_fraction!(1.0e-5), + }) .expect("the fixture schedule is valid") } @@ -1494,38 +1527,40 @@ fn minimal_schedule() -> TrainingSchedule { /// boundary's own certificates (`compute::projector::tests`) pin the bit-exact publish contracts, /// and these fixtures certify the fit's composition. fn projector_options() -> ProjectorOptions { - let mut options = ProjectorOptions::ratified(); + let mut options = ProjectorOptions::live(); options.architecture = Architecture { - width: NonZero::new(8).expect("the fixture width is nonzero"), - residual_blocks: NonZero::new(1).expect("the fixture depth is nonzero"), - representation_dimensions: NonZero::new(PROJECTOR_DIMENSIONS) - .expect("the projector width is nonzero"), - role_dimensions: NonZero::new(4).expect("the fixture role width is nonzero"), - condition_dimensions: NonZero::new(1).expect("the fixture condition width is nonzero"), + width: nz!(8), + residual_blocks: nz!(1), + representation_dimensions: nz!(PROJECTOR_DIMENSIONS), + role_dimensions: nz!(4), + condition_dimensions: nz!(1), }; - options.schedule = TrainingSchedule::new( - NonZero::new(12).expect("the fixture step count is nonzero"), - 6, - NonZero::new(4).expect("the fixture cadence is nonzero"), - positive_unit_fraction!(1.0e-3), - unit_fraction!(1.0e-5), - ) + options.schedule = TrainingSchedule::new(TrainingScheduleOptions { + steps: nz!(12), + boundary: 6, + refresh_interval: nz!(4), + initial_learning_rate: positive_unit_fraction!(1.0e-3), + minimum_learning_rate: unit_fraction!(1.0e-5), + }) .expect("the fixture schedule is valid"); options.plan = BatchPlan { - semantic_pairs: NonZero::new(8).expect("the fixture draw is nonzero"), + semantic_pairs: nz!(8), ordinary_pairs: 4, relation_types: 1, - relation_cap: NonZero::new(4).expect("the fixture cap is nonzero"), + relation_cap: nz!(4), hard_queries: 2, landmark_anchors: 2, temporal_anchors: 0, }; - options.lens = RelationLens::new( - CoincidentEnergy::new(non_negative!(0.5), positive!(0.5)), - Positive::new(0.25).expect("the fixture temperature is positive"), - Positive::new(1.0e-8).expect("the fixture scale guard is positive"), - ); - options.forward_rows = NonZero::new(16).expect("the fixture slice is nonzero"); + options.lens = RelationLens { + coincident: CoincidentEnergy { + radius: non_negative!(0.5), + threshold: positive!(0.5), + }, + temperature: positive!(0.25), + epsilon: positive!(1.0e-8), + }; + options.forward_rows = nz!(16); options } @@ -1567,7 +1602,7 @@ fn default_placement_is_the_trained_projector() { let PlacementOptions::Projector(options) = &config.placement else { panic!("the default placement should train the projector"); }; - assert_eq!(*options, ProjectorOptions::ratified()); + assert_eq!(*options, ProjectorOptions::live()); assert_eq!(options.schedule.steps().get(), 20_000); assert_eq!(options.schedule.boundary(), 5_000); assert_eq!(options.ladder.canonical.to_bits(), 1.0_f32.to_bits()); @@ -1591,8 +1626,12 @@ async fn forceless_projector_publishes_the_baseline_step() { overrides: vec![PolicyOverride { relation: OntologyRowId::new(2), source: PolicySource::Human, - distribution: Posterior::new([0.0, 0.0, 1.0]) - .expect("the asserted distribution sums to one"), + distribution: Posterior::new([ + unit_fraction!(0.0), + unit_fraction!(0.0), + unit_fraction!(1.0), + ]) + .expect("the asserted distribution sums to one"), }], .. }, @@ -1725,7 +1764,7 @@ fn assert_paired_replay(published: &Utf8Path, repository: &SaltRepository) { assert_eq!( deciles.iter().map(|stratum| stratum.selected).sum::(), 2, - "every drawn control lands in a stratum" + "the strata count every drawn control once" ); for stratum in deciles { assert_eq!(stratum.displacement.is_some(), stratum.selected > 0); @@ -1748,8 +1787,12 @@ async fn trained_lens_publishes_the_canonical_step_aligned() { overrides: vec![PolicyOverride { relation: OntologyRowId::new(2), source: PolicySource::Human, - distribution: Posterior::new([0.0, 1.0, 0.0]) - .expect("the asserted distribution sums to one"), + distribution: Posterior::new([ + unit_fraction!(0.0), + unit_fraction!(1.0), + unit_fraction!(0.0), + ]) + .expect("the asserted distribution sums to one"), }], .. }, @@ -1784,11 +1827,9 @@ async fn trained_lens_publishes_the_canonical_step_aligned() { .projector .as_ref() .expect("a trained placement records projector evidence"); - assert!( - matches!( - evidence.boundary, - Some(FrozenRadiusEvidence::Measured { .. }) - ), + assert_matches!( + evidence.boundary, + Some(FrozenRadiusEvidence::Measured { .. }), "the boundary freezes the radius measured from the reviewed pairs" ); @@ -1973,19 +2014,26 @@ async fn duplicate_rows_train_distinct_and_publish_the_row_domain() { // takes a 0.01 Coincident radius in place of the fixture's 0.5. let verdicts = proximal_link_verdicts(); let mut options = projector_options(); - options.lens = RelationLens::new( - CoincidentEnergy::new(non_negative!(0.01), positive!(0.5)), - Positive::new(0.25).expect("the fixture temperature is positive"), - Positive::new(1.0e-8).expect("the fixture scale guard is positive"), - ); + options.lens = RelationLens { + coincident: CoincidentEnergy { + radius: non_negative!(0.01), + threshold: positive!(0.5), + }, + temperature: Positive::new(0.25).expect("the fixture temperature is positive"), + epsilon: Positive::new(1.0e-8).expect("the fixture scale guard is positive"), + }; let config = FitConfig { placement: PlacementOptions::Projector(options), policy: PolicyOptions { overrides: vec![PolicyOverride { relation: OntologyRowId::new(2), source: PolicySource::Human, - distribution: Posterior::new([0.0, 1.0, 0.0]) - .expect("the asserted distribution sums to one"), + distribution: Posterior::new([ + unit_fraction!(0.0), + unit_fraction!(1.0), + unit_fraction!(0.0), + ]) + .expect("the asserted distribution sums to one"), }], .. }, @@ -2083,8 +2131,12 @@ async fn vacuous_placement_trains_without_reviews() { overrides: vec![PolicyOverride { relation: OntologyRowId::new(2), source: PolicySource::Human, - distribution: Posterior::new([0.0, 1.0, 0.0]) - .expect("the asserted distribution sums to one"), + distribution: Posterior::new([ + unit_fraction!(0.0), + unit_fraction!(1.0), + unit_fraction!(0.0), + ]) + .expect("the asserted distribution sums to one"), }], .. }; @@ -2108,14 +2160,11 @@ async fn vacuous_placement_trains_without_reviews() { &NoProgress, ) .await; - assert!( - matches!( - result, - Err(FitError::Compute(ComputeError::Projector( - ProjectorError::Train(TrainError::MissingProximalReviews) - ))), - ), - "proximal force without reviews should refuse", + assert_matches!( + result, + Err(FitError::Compute(ComputeError::Projector( + ProjectorError::Train(TrainError::MissingProximalReviews) + ))) ); let root = GenerationRoot::new(scratch("vacuous-trains")).expect("the root should open"); @@ -2213,8 +2262,12 @@ async fn canonical_condition_outside_the_schedule_publishes_nothing() { overrides: vec![PolicyOverride { relation: OntologyRowId::new(2), source: PolicySource::Human, - distribution: Posterior::new([0.0, 1.0, 0.0]) - .expect("the asserted distribution sums to one"), + distribution: Posterior::new([ + unit_fraction!(0.0), + unit_fraction!(1.0), + unit_fraction!(0.0), + ]) + .expect("the asserted distribution sums to one"), }], .. }, @@ -2235,14 +2288,12 @@ async fn canonical_condition_outside_the_schedule_publishes_nothing() { &NoProgress, ) .await; - assert!( - matches!( - result, - Err(FitError::Compute(ComputeError::Projector( - ProjectorError::Canonical(CanonicalError::UnknownStep { .. }) - ))), - ), - "an off-schedule canonical condition should abort the fit", + assert_matches!( + result, + Err(FitError::Compute(ComputeError::Projector( + ProjectorError::Canonical(CanonicalError::UnknownStep { .. }) + ))), + "an off-schedule canonical condition should abort the fit" ); let entries: Vec<_> = fs::read_dir(&path) @@ -2450,8 +2501,12 @@ async fn edge_artifacts_publish_and_read_back() { .map(|relation| PolicyOverride { relation: OntologyRowId::new(relation), source: PolicySource::Human, - distribution: Posterior::new([0.25, 0.5, 0.25]) - .expect("the fixture distribution sums to one"), + distribution: Posterior::new([ + unit_fraction!(0.25), + unit_fraction!(0.5), + unit_fraction!(0.25), + ]) + .expect("the fixture distribution sums to one"), }) .collect(); @@ -2525,7 +2580,7 @@ async fn edge_artifacts_publish_and_read_back() { .expect("the ontology identities should validate"); assert_eq!(ontology_ids.len(), 4); assert_eq!( - ontology_ids.id(OntologyRowId::new(2)), + ontology_ids.key_of(OntologyRowId::new(2)), Some(MemoryOntologyId::new(2)) ); assert_eq!( diff --git a/libs/@local/graph/atlas/src/salt/importance/mod.rs b/libs/@local/graph/atlas/src/salt/importance/mod.rs index 9969eb60f05..c93e1195be3 100644 --- a/libs/@local/graph/atlas/src/salt/importance/mod.rs +++ b/libs/@local/graph/atlas/src/salt/importance/mod.rs @@ -17,9 +17,8 @@ mod tests; /// The importance signal selected for a fit. /// -/// The manifest echoes the variant and the metadata's ranking origin mirrors it, so a published -/// generation names the signal its delivery order ran under. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +/// Uses [`Self::IncidentDegree`] by default. +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) enum RankingConfig { /// A constant column. /// @@ -97,8 +96,7 @@ impl<'graph> DegreeImportance<'graph> { impl ImportanceSignal for DegreeImportance<'_> { #[expect( clippy::cast_precision_loss, - reason = "degrees stay exactly representable in f32 far beyond any plausible fan-in; the \ - documented rounding beyond 2^24 reorders near-ties only" + reason = "f32 scores are finite for resident degrees, with rounded ties beyond 2^24" )] fn derive(&self, rows: usize) -> IdVec { assert_eq!( diff --git a/libs/@local/graph/atlas/src/salt/knn/descent.rs b/libs/@local/graph/atlas/src/salt/knn/descent.rs index 8751ef5ed85..9d0edc3d3d4 100644 --- a/libs/@local/graph/atlas/src/salt/knn/descent.rs +++ b/libs/@local/graph/atlas/src/salt/knn/descent.rs @@ -81,7 +81,7 @@ const DEFAULT_MAXIMUM_ITERATIONS: usize = 20; const DEFAULT_TERMINATION: f64 = 0.001; /// Pinned NN-Descent sampling, convergence, and termination settings. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct NnDescentOptions { /// Candidates sampled per side (new and old, forward and reverse) per row per iteration. /// @@ -397,8 +397,8 @@ where /// a rate that can exceed `1`, and need not decrease monotonically between iterations. #[expect( clippy::cast_precision_loss, - reason = "an accepted-update count and an entry count both stay far below exact f64 integer \ - precision" + reason = "accepted updates and stored entries deliberately convert to f64 for progress \ + reporting. The count conversions and rate division may round" )] fn accepted_per_entry(accepted: u64, entries: usize) -> f64 { accepted as f64 / entries as f64 @@ -414,8 +414,9 @@ where clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss, - reason = "the entry count is far below exact f64 integer precision and the threshold only \ - gates a loop" + reason = "the entry count deliberately converts to f64 for rounded threshold arithmetic. \ + The result of ceil is cast to u64 with saturation and only controls loop \ + termination" )] fn construct

( &mut self, @@ -521,7 +522,7 @@ where let list = list .entries .lock() - .expect("the join finished; no offer holds a lock"); + .expect("the completed join should leave the list mutex unpoisoned"); for (slot, entry) in slots.iter_mut().zip(list.iter()) { *slot = Neighbour { diff --git a/libs/@local/graph/atlas/src/salt/knn/hannoy.rs b/libs/@local/graph/atlas/src/salt/knn/hannoy.rs index 7df939a97c6..de8e15fa1c7 100644 --- a/libs/@local/graph/atlas/src/salt/knn/hannoy.rs +++ b/libs/@local/graph/atlas/src/salt/knn/hannoy.rs @@ -76,7 +76,7 @@ const DEFAULT_EF_CONSTRUCTION: usize = 256; const DEFAULT_EF_SEARCH: usize = 128; /// Pinned hannoy storage, build, and query settings. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct HannoyIndexOptions { /// Upper bound of the LMDB memory map, in bytes. /// diff --git a/libs/@local/graph/atlas/src/salt/knn/mod.rs b/libs/@local/graph/atlas/src/salt/knn/mod.rs index 0b0a36e3627..4e3b1691573 100644 --- a/libs/@local/graph/atlas/src/salt/knn/mod.rs +++ b/libs/@local/graph/atlas/src/salt/knn/mod.rs @@ -39,7 +39,7 @@ use rand::{Rng, SeedableRng}; use crate::{ dataset::PROJECTOR_DIMENSIONS, - math::{AlignedVecN, NonNegative}, + math::{AlignedVecN, NonNegative, nz}, progress::Progress, }; @@ -56,8 +56,7 @@ pub(crate) mod table; mod tests; /// Stored neighbours per row of the persisted table. -pub(crate) const DEFAULT_NEIGHBOURS: NonZero = - NonZero::new(30).expect("the default neighbour count is nonzero"); +pub(crate) const DEFAULT_NEIGHBOURS: NonZero = nz!(30); /// One node row's projector representation, keyed for insertion. #[derive(Debug, Copy, Clone)] diff --git a/libs/@local/graph/atlas/src/salt/knn/recall.rs b/libs/@local/graph/atlas/src/salt/knn/recall.rs index 8b4ed9cdcfa..ec161a37740 100644 --- a/libs/@local/graph/atlas/src/salt/knn/recall.rs +++ b/libs/@local/graph/atlas/src/salt/knn/recall.rs @@ -90,7 +90,7 @@ const DEFAULT_PILOT: NonZero = nz!(688); const DEFAULT_BUDGET: Duration = Duration::from_secs(600); /// Pinned sampling and admission settings for one recall spot check. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct SpotCheckOptions { /// Exact neighbours compared per sampled row. /// diff --git a/libs/@local/graph/atlas/src/salt/knn/report/mod.rs b/libs/@local/graph/atlas/src/salt/knn/report/mod.rs index e6a0fad4b56..9b6208a6e52 100644 --- a/libs/@local/graph/atlas/src/salt/knn/report/mod.rs +++ b/libs/@local/graph/atlas/src/salt/knn/report/mod.rs @@ -29,6 +29,7 @@ use hashql_core::id::IdSlice; use crate::{ dataset::PROJECTOR_DIMENSIONS, file::{ + ArtifactFile as _, array::{ArrayFile, OpenArrayError}, generation::{CurrentError, GenerationId, GenerationRoot, OpenError}, }, diff --git a/libs/@local/graph/atlas/src/salt/knn/report/tests.rs b/libs/@local/graph/atlas/src/salt/knn/report/tests.rs index aa71817c2ff..873056fb39b 100644 --- a/libs/@local/graph/atlas/src/salt/knn/report/tests.rs +++ b/libs/@local/graph/atlas/src/salt/knn/report/tests.rs @@ -1,4 +1,7 @@ -use core::sync::atomic::{AtomicU64, Ordering}; +use core::{ + assert_matches, + sync::atomic::{AtomicU64, Ordering}, +}; use std::{fs, path::PathBuf}; use zerocopy::IntoBytes as _; @@ -6,7 +9,10 @@ use zerocopy::IntoBytes as _; use super::{Representations, SetupError}; use crate::{ dataset::PROJECTOR_DIMENSIONS, - file::array::{ArrayFile, ArrayVariant, ArrayWriter, Dim}, + file::{ + ArtifactFile as _, + array::{ArrayFile, ArrayVariant, ArrayWriter, Dim}, + }, }; /// A scratch array file for mapped representation fixtures. @@ -84,5 +90,5 @@ fn rows_refuse_another_width() { let written = TempFile::representation_rows(&[1.0, 2.0], 8); let opened = representations(&written); - assert!(matches!(opened.rows(), Err(SetupError::Width))); + assert_matches!(opened.rows(), Err(SetupError::Width)); } diff --git a/libs/@local/graph/atlas/src/salt/knn/tests.rs b/libs/@local/graph/atlas/src/salt/knn/tests.rs index 21348f2f489..8a77e6ad033 100644 --- a/libs/@local/graph/atlas/src/salt/knn/tests.rs +++ b/libs/@local/graph/atlas/src/salt/knn/tests.rs @@ -33,7 +33,7 @@ use crate::{ }, identity::NodeRowId, math::{ - AlignedVecN, BoxedVecN, d_non_negative, non_negative, open_unit_fraction, unit_fraction, + AlignedVecN, BoxedVecN, d_non_negative, non_negative, nz, open_unit_fraction, unit_fraction, }, progress::{Batch, DescentIteration, NoProgress, Progress}, random::normal_quantile, @@ -351,7 +351,7 @@ fn fan_fixture(rows: usize, step: f32) -> Vec<[f32; PROJECTOR_DIMENSIONS]> { .map(|index| { #[expect( clippy::cast_precision_loss, - reason = "test indices are tiny exact integers" + reason = "fixture indices below 128 are exactly representable in f32" )] let angle = index as f32 * step; let mut row = [0.0; PROJECTOR_DIMENSIONS]; @@ -364,7 +364,7 @@ fn fan_fixture(rows: usize, step: f32) -> Vec<[f32; PROJECTOR_DIMENSIONS]> { /// Returns the neighbour width `2` for the small plane fixtures. fn two_neighbours() -> NonZero { - NonZero::new(2).expect("two is nonzero") + nz!(2) } /// Creates the fixed-seed generator for neighbour-construction fixtures. @@ -479,8 +479,7 @@ fn from_lists_over_the_smallest_fixture_measures_the_miri_cost() { let lists = tiny_neighbour_lists(); let start = std::time::Instant::now(); - let knn = Knn::from_lists::(&lists, NonZero::new(1).expect("one is nonzero")) - .expect("the fixture is well-formed"); + let knn = Knn::from_lists::(&lists, nz!(1)).expect("the fixture is well-formed"); let elapsed = start.elapsed(); assert_eq!(knn.view().rows(), 3); @@ -567,15 +566,11 @@ fn build_rejects_unsatisfiable_shapes() { // Construction clamps the width to the corpus; the table's stored // count still must stay below the row domain. - let lists = lists_via( - ExactIndex::from_rows(&[]), - matrix.view(), - NonZero::new(4).expect("four is nonzero"), - ) - .expect("the clamped construction succeeds"); + let lists = lists_via(ExactIndex::from_rows(&[]), matrix.view(), nz!(4)) + .expect("the clamped construction succeeds"); assert_eq!(lists.width(), 3); assert_matches!( - Knn::from_lists::(&lists, NonZero::new(4).expect("four is nonzero")), + Knn::from_lists::(&lists, nz!(4)), Err(KnnError::Invalid(KnnValidationError::NeighbourBounds { neighbours: 4, rows: 4, @@ -586,7 +581,7 @@ fn build_rejects_unsatisfiable_shapes() { let narrow = lists_via(ExactIndex::from_rows(&[]), matrix.view(), two_neighbours()) .expect("the fixture is well-formed"); assert_matches!( - Knn::from_lists::(&narrow, NonZero::new(3).expect("three is nonzero")), + Knn::from_lists::(&narrow, nz!(3)), Err(KnnError::ListsWidth { width: 2, neighbours: 3, @@ -636,7 +631,7 @@ fn descent_converges_on_known_geometry() { let rows = fan_fixture(64, 0.02); let matrix = Matrix::new(&rows); let embeddings = matrix.view(); - let width = NonZero::new(4).expect("four is nonzero"); + let width = nz!(4); let lists = NnDescent::new(NnDescentOptions::default()) .construct(embeddings, width, test_rng(), &NoProgress) @@ -759,12 +754,7 @@ fn descent_clamps_the_width_to_the_corpus() { let matrix = Matrix::new(&rows); let lists = NnDescent::new(NnDescentOptions::default()) - .construct( - matrix.view(), - NonZero::new(16).expect("sixteen is nonzero"), - test_rng(), - &NoProgress, - ) + .construct(matrix.view(), nz!(16), test_rng(), &NoProgress) .expect("the clamped construction succeeds"); assert_eq!(lists.width(), 3, "the width clamps to every non-self row"); } @@ -777,12 +767,7 @@ fn an_observed_construction_reports_its_insertion_then_its_readback() { // The backend starts empty, and the construction fills it. IndexConstruction::new(ExactIndex::from_rows(&[])) - .construct( - matrix.view(), - NonZero::new(4).expect("four is nonzero"), - test_rng(), - &progress, - ) + .construct(matrix.view(), nz!(4), test_rng(), &progress) .expect("the fixture is well-formed"); // below the cadence, each loop reports exactly once at its last row. This backend names no @@ -803,7 +788,7 @@ fn an_observed_construction_reports_its_insertion_then_its_readback() { fn watching_a_construction_does_not_change_its_lists() { let rows = fan_fixture(64, 0.02); let matrix = Matrix::new(&rows); - let width = NonZero::new(4).expect("four is nonzero"); + let width = nz!(4); let watched = IndexConstruction::new(ExactIndex::from_rows(&[])) .construct( @@ -836,12 +821,7 @@ fn an_observed_descent_reports_every_iteration_it_ran() { let progress = RecordingProgress::default(); NnDescent::new(options) - .construct( - matrix.view(), - NonZero::new(4).expect("four is nonzero"), - test_rng(), - &progress, - ) + .construct(matrix.view(), nz!(4), test_rng(), &progress) .expect("the fixture is well-formed"); let iterations: Vec = progress @@ -1046,7 +1026,7 @@ fn spot_check_honours_configured_options() { &index, matrix.view(), recall::SpotCheckOptions { - neighbours: NonZero::new(3).expect("three is nonzero"), + neighbours: nz!(3), .. }, Xoshiro256PlusPlus::seed_from_u64(42), @@ -1090,10 +1070,7 @@ fn spot_check_sizes_a_decisive_verdict_sample_at_the_pilot_floor() { let check = recall::spot_check( &index, matrix.view(), - recall::SpotCheckOptions { - pilot: NonZero::new(4).expect("four is nonzero"), - .. - }, + recall::SpotCheckOptions { pilot: nz!(4), .. }, Xoshiro256PlusPlus::seed_from_u64(42), ) .expect("the exact backend answers every query"); @@ -1121,7 +1098,7 @@ fn spot_check_sizes_the_verdict_sample_to_the_measured_clearance() { matrix.view(), recall::SpotCheckOptions { minimum_recall: unit_fraction!(0.93), - pilot: NonZero::new(4).expect("four is nonzero"), + pilot: nz!(4), .. }, Xoshiro256PlusPlus::seed_from_u64(42), @@ -1151,7 +1128,7 @@ fn spot_check_stops_at_the_sampling_budget() { matrix.view(), recall::SpotCheckOptions { minimum_recall: unit_fraction!(0.94), - pilot: NonZero::new(4).expect("four is nonzero"), + pilot: nz!(4), budget: Duration::ZERO, .. }, @@ -1465,7 +1442,7 @@ fn a_watched_hannoy_construction_reports_its_phases_between_the_loops() { ) .construct( matrix.view(), - NonZero::new(4).expect("four is nonzero"), + nz!(4), Xoshiro256PlusPlus::seed_from_u64(42), &progress, ) diff --git a/libs/@local/graph/atlas/src/salt/ladder/mod.rs b/libs/@local/graph/atlas/src/salt/ladder/mod.rs index 0f98e1e68ba..903a72f5150 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/mod.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/mod.rs @@ -45,15 +45,31 @@ mod tests; pub(crate) use self::error::{CanonicalError, ConditionsError, LadderError}; +/// A relation-lens schedule awaiting length, baseline and ordering checks. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct UnvalidatedConditions(Cow<'static, [NonNegative]>); + +impl TryFrom for Conditions { + type Error = ConditionsError; + + fn try_from(UnvalidatedConditions(values): UnvalidatedConditions) -> Result { + Self::new(values) + } +} + +impl From for UnvalidatedConditions { + fn from(conditions: Conditions) -> Self { + Self(conditions.values) + } +} + /// A validated relation-lens condition schedule. /// -/// Construction validates the schedule. A schedule has at least two steps and opens at the -/// zero-condition step that every other step measures against. The steps ascend strictly, and -/// every value is finite and non-negative with a canonical sign of zero by construction -/// ([`NonNegative`]), so a step's bits identify its value in reproducibility records with no -/// `-0.0` alias to guard against. A [`Cow`] carries the values so the reference schedule is a -/// constant. -#[derive(Debug, Clone, PartialEq, Eq)] +/// A schedule has at least two steps and opens at the zero-condition step, `0.0`. Every later value +/// strictly exceeds its predecessor. [`NonNegative`] supplies finite, non-negative values with +/// canonical positive zero: a step's bits identify its value without a `-0.0` alias. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "UnvalidatedConditions", into = "UnvalidatedConditions")] pub(crate) struct Conditions { values: Cow<'static, [NonNegative]>, } @@ -149,11 +165,9 @@ pub(crate) struct Field<'coordinates, I> { /// The projection schedule and the condition selected for canonical coordinates. /// -/// The canonical value names a schedule member exactly ([`select_canonical`]): equality on -/// [`NonNegative`] is bit equality. A value outside the schedule is a configuration -/// contradiction, and [`Self::canonical_index`] decides the membership from the options alone, -/// so a fit refuses the contradiction before it trains. -#[derive(Debug, Clone, PartialEq, Eq)] +/// The canonical value must name a schedule member exactly: equality on [`NonNegative`] is bit +/// equality. Use [`Self::canonical_index`] to check membership before projection. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct LadderOptions { /// The condition schedule, [`Conditions::REFERENCE`] by default. pub conditions: Conditions = Conditions::REFERENCE, diff --git a/libs/@local/graph/atlas/src/salt/ladder/paired/evidence/mod.rs b/libs/@local/graph/atlas/src/salt/ladder/paired/evidence/mod.rs index 88c0fbbc289..c80edc9720d 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/paired/evidence/mod.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/paired/evidence/mod.rs @@ -173,10 +173,10 @@ impl PairAggregates { distance: MovementAggregate::over(&distances), rank: MovementAggregate::over(&ranks), contracting: UnitFraction::ratio(contracted, count).expect( - "the loop counts each reading at most once, so the part is within its total", + "the loop counts each reading at most once. The subset count is within the total", ), rank_improving: UnitFraction::ratio(improved, count).expect( - "the loop counts each reading at most once, so the part is within its total", + "the loop counts each reading at most once. The subset count is within the total", ), } } @@ -322,7 +322,7 @@ impl ControlDecile { let stratum = uppers .iter() .position(|&upper| reading.anchor_distance <= upper) - .expect("a drawn control is a candidate, so its reading is in the census range"); + .expect("a drawn control is a candidate. Its reading is within the census range"); members[stratum].push(DFinite::from(reading.displacement)); } @@ -425,14 +425,13 @@ fn nearest_rank(sorted: &[T], fraction: f64) -> T { #[expect( clippy::cast_precision_loss, - reason = "reading populations stay far below exact f64 integer precision" + reason = "population conversion uses f64 arithmetic as documented" )] let population = sorted.len() as f64; #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, - reason = "the rank is a positive product of a fraction with the population, so it never \ - exceeds the population and never carries a sign" + reason = "the non-negative rounded rank is the specified index calculation" )] let rank = (fraction * population).ceil() as usize; sorted[rank - 1] diff --git a/libs/@local/graph/atlas/src/salt/ladder/paired/evidence/tests.rs b/libs/@local/graph/atlas/src/salt/ladder/paired/evidence/tests.rs index e88036f454a..51ee6e41732 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/paired/evidence/tests.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/paired/evidence/tests.rs @@ -5,7 +5,7 @@ #![expect( clippy::float_cmp, - reason = "the oracles restate exact decimal readings, so equality is the contract" + reason = "the quantile oracle returns the same represented input reading" )] #![expect( clippy::cast_precision_loss, @@ -241,7 +241,7 @@ fn control_deciles_stratify_the_candidate_census() { assert_eq!(deciles[1].selected, 1, "3.0 lies above 2.0 and reaches 4.0"); assert_eq!( deciles[9].selected, 1, - "the census maximum lands in the final stratum" + "the census maximum belongs to the final stratum" ); for decile in &deciles[2..9] { assert_eq!(decile.selected, 0); diff --git a/libs/@local/graph/atlas/src/salt/ladder/paired/fixtures.rs b/libs/@local/graph/atlas/src/salt/ladder/paired/fixtures.rs index fd441de31aa..b065e0382ab 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/paired/fixtures.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/paired/fixtures.rs @@ -1,12 +1,7 @@ //! Common metadata and geometry fixtures for paired-movement tests. //! -//! The identity pins freeze the exact bytes [`snapshot`] and [`reproducibility`] serialize -//! into, and the writer pins record the salt they derive, so every sibling's tests must agree -//! on one definition of these inputs. A definition that drifted in one test module and not -//! another would fail a byte pin far from the drift, so the shared inputs live here and each -//! test module keeps the fixtures it alone consumes. - -use core::num::NonZero; +//! Identity and writer tests use the same [`snapshot`] and [`reproducibility`] values to compare +//! their serialized bytes and derived salts. use hashql_core::id::IdSlice; @@ -39,7 +34,7 @@ pub(super) fn config() -> FitConfig { FitConfig { seed: 0xC2, selection: SelectionOptions { - maximum_count: NonZero::new(512).expect("the fixture capacity is nonzero"), + maximum_count: nz!(512), .. }, curve: AffinityCurve::new(positive!(1.5), positive!(0.9)), diff --git a/libs/@local/graph/atlas/src/salt/ladder/paired/identity/mod.rs b/libs/@local/graph/atlas/src/salt/ladder/paired/identity/mod.rs index d72a06aacc8..4797d05ca67 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/paired/identity/mod.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/paired/identity/mod.rs @@ -155,8 +155,7 @@ impl DrawRule { /// Returns [`EncodeError`] for serialization or output-write failure. #[expect( clippy::unused_self, - reason = "the receiver is the recognition proof: only `RuleIdentity::recognize` mints it, \ - and a later identity's dispatch consumes it" + reason = "the receiver limits these operations to a recognized draw rule" )] fn write_preimage( self, @@ -211,8 +210,7 @@ impl DrawRule { /// subject families. #[expect( clippy::unused_self, - reason = "the receiver is the recognition proof: only `RuleIdentity::recognize` mints it, \ - and a later identity's dispatch consumes it" + reason = "the receiver limits these operations to a recognized draw rule" )] #[must_use] fn order_key(self, salt: DrawSalt, subject: &[u8]) -> OrderKey { diff --git a/libs/@local/graph/atlas/src/salt/ladder/paired/identity/tests.rs b/libs/@local/graph/atlas/src/salt/ladder/paired/identity/tests.rs index d3ef7653060..c4ec8651309 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/paired/identity/tests.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/paired/identity/tests.rs @@ -102,17 +102,22 @@ fn preimage_round_trips_through_the_document_serde_paths() { /// The fixture includes [`FitConfig`](crate::salt::fit::FitConfig) defaults in its serialized /// inputs. #[test] -fn identity_one_preimage_bytes_stay_frozen() { +fn preimage_fixture() { let preimage = preimage(); - assert_eq!(preimage.len(), 4488); assert_eq!( - Sha256Digest::of(&preimage).to_string(), - "e8dac83a09203656b0a2ab5ca24582b20b9b63fe35038652d19ec5833f7af35b" - ); - assert_eq!( - serde_json::to_value(salt()).expect("a derived salt serializes"), - serde_json::json!("37fe1e70c11a4ed8775051d119e1d45661500bea76f8f458098719c26a3e8804") + ( + preimage.len(), + Sha256Digest::of(&preimage).to_string(), + serde_json::to_value(salt()).expect("the derived salt should serialize"), + ), + ( + 4727, + "7916d61de311b48094806264dbef20801589be00af1d0e4bec8471d17b728c33".to_owned(), + serde_json::json!("75035875102aa50ef6f9d11bd0d0cf417a5291ea146be40363d72e1c83f9e4d1"), + ), + "the fixture's preimage and salt should match their fixed expectations:\n{}", + str::from_utf8(&preimage).expect("the JSON preimage should be UTF-8"), ); } diff --git a/libs/@local/graph/atlas/src/salt/ladder/paired/measure/tests.rs b/libs/@local/graph/atlas/src/salt/ladder/paired/measure/tests.rs index a70e64344c0..0b75c50d277 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/paired/measure/tests.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/paired/measure/tests.rs @@ -160,7 +160,7 @@ fn an_injected_movement_refusal_keeps_its_counts_and_no_partial_aggregates() { #[test] #[expect( clippy::float_cmp, - reason = "the forbidden-shortcut restatement compares exact decimal literals" + reason = "the median differences are exactly representable integers" )] fn the_readout_reproduces_its_bytes_and_pins_exact_decimal_aggregates() { let (groups, edges) = readout_index(); diff --git a/libs/@local/graph/atlas/src/salt/ladder/paired/movement/mod.rs b/libs/@local/graph/atlas/src/salt/ladder/paired/movement/mod.rs index 05302e69f47..8064cc581b6 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/paired/movement/mod.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/paired/movement/mod.rs @@ -40,17 +40,16 @@ use hashql_core::heap::Scratch; use crate::{ identity::NodeRowId, - math::{DNonNegative, FinitePointField, KdTree, Vec2x4T}, + math::{DNonNegative, FinitePointField, KdTree, Vec2x4T, nz}, }; /// The rank-readout window `k`, the size of one row's local neighbourhood. /// -/// A rank reading counts within the union of both steps' `k`-sets, so the readout resolves rank -/// movement inside the window and saturates beyond it, and no rank exceeds `1 + 2k`. The window -/// is a readout resolution rather than a derived quantity, and the evidence body records it -/// beside every generation's readings, so a persisted reading stays interpretable if the window -/// moves. -pub(super) const RANK_WINDOW: NonZero = NonZero::new(256).expect("256 is not zero"); +/// The window is 256 rows per step. A union-domain rank never exceeds 1 + 2k, and partners beyond +/// the union's distances saturate at 1 + |U|. The evidence records this resolution to keep readings +/// interpretable if the window changes. Revisit it using the frequency of saturated ranks and the +/// readout cost. +pub(super) const RANK_WINDOW: NonZero = nz!(256); /// The reading of one drawn pair, its distance and local rank at both steps. /// @@ -297,7 +296,7 @@ impl<'frame> Movement<'frame> { let readout = anchors.nearest_point_in(self.zero[row], NonZero::::MIN, scratch); let nearest = readout .first() - .expect("readings run only under a nonempty draw, so an anchor exists"); + .expect("the nearest-anchor query should return a candidate"); nearest.distance_squared.sqrt() } } diff --git a/libs/@local/graph/atlas/src/salt/ladder/paired/movement/tests.rs b/libs/@local/graph/atlas/src/salt/ladder/paired/movement/tests.rs index baa1a91856f..682e3c8e8b9 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/paired/movement/tests.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/paired/movement/tests.rs @@ -16,7 +16,7 @@ use rand_xoshiro::Xoshiro256PlusPlus; use super::{AnchorRowId, ControlMovement, Movement, MovementError, PairMovement}; use crate::{ identity::NodeRowId, - math::{DNonNegative, FinitePointField, KdTree, Vec2, d_non_negative}, + math::{DNonNegative, FinitePointField, KdTree, Vec2, d_non_negative, nz}, salt::ladder::paired::fixtures::frame, }; @@ -139,12 +139,8 @@ fn an_exact_distance_tie_resolves_by_row_identity() { Vec2::new(0.0, 1.0), Vec2::new(1.0, 0.0), ]; - let movement = Movement::new( - frame(&points), - frame(&points), - NonZero::new(2).expect("two is nonzero"), - ) - .expect("the frames are finite and equal"); + let movement = Movement::new(frame(&points), frame(&points), nz!(2)) + .expect("the frames are finite and equal"); let scratch = Scratch::new(); // row 1 breaks the distance tie ahead of partner 2. Reversing the partners excludes that @@ -191,12 +187,8 @@ fn a_partner_outside_one_step_ranks_over_the_union_domain() { Vec2::new(0.5, 0.0), Vec2::new(9.0, 9.0), ]; - let movement = Movement::new( - frame(&zero), - frame(&canonical), - NonZero::new(2).expect("two is nonzero"), - ) - .expect("the frames are finite and equal"); + let movement = Movement::new(frame(&zero), frame(&canonical), nz!(2)) + .expect("the frames are finite and equal"); let scratch = Scratch::new(); assert_eq!( @@ -225,12 +217,8 @@ fn a_partner_outside_one_step_ranks_over_the_union_domain() { fn control_readings_are_displacement_and_anchor_proximity() { let zero = [Vec2::new(0.0, 0.0), Vec2::new(3.0, 4.0)]; let canonical = [Vec2::new(0.0, 0.0), Vec2::new(3.0, 16.0)]; - let movement = Movement::new( - frame(&zero), - frame(&canonical), - NonZero::new(1).expect("one is nonzero"), - ) - .expect("the frames are finite and equal"); + let movement = Movement::new(frame(&zero), frame(&canonical), nz!(1)) + .expect("the frames are finite and equal"); let anchor_frame = [Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)]; let anchors = KdTree::build(FinitePointField::new_unchecked( @@ -251,7 +239,7 @@ fn control_readings_are_displacement_and_anchor_proximity() { fn mismatched_rows() { let zero = [Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0)]; let short = [Vec2::new(0.0, 0.0)]; - let k = NonZero::new(1).expect("one is nonzero"); + let k = nz!(1); assert_eq!( Movement::new(frame(&zero), frame(&short), k).expect_err("the row counts disagree"), diff --git a/libs/@local/graph/atlas/src/salt/ladder/report/mod.rs b/libs/@local/graph/atlas/src/salt/ladder/report/mod.rs index 2e8e13066ff..1c61630bbf8 100644 --- a/libs/@local/graph/atlas/src/salt/ladder/report/mod.rs +++ b/libs/@local/graph/atlas/src/salt/ladder/report/mod.rs @@ -57,6 +57,7 @@ use crate::{ dataset::PROJECTOR_DIMENSIONS, device::PhysicalDevice, file::{ + ArtifactFile as _, array::ArrayFile, attraction::read::AttractionFile, generation::{GenerationId, GenerationRoot}, @@ -359,7 +360,7 @@ impl<'source> LadderSources<'source> { let PlacementOptions::Projector(options) = &repository.metadata.reproducibility.config.placement else { - panic!("the generation placed rows by landmark baseline; no ladder exists to read"); + panic!("ladder reporting requires projector placement"); }; let evidence = repository @@ -370,7 +371,7 @@ impl<'source> LadderSources<'source> { .expect("a projector placement records its training evidence") .ladder .as_ref() - .expect("the corpus carries relation force; a forceless ladder never publishes"); + .expect("should contain recorded ladder evidence for reporting"); let schedule = options.ladder.conditions.values(); assert_eq!( diff --git a/libs/@local/graph/atlas/src/salt/landmark/artifact.rs b/libs/@local/graph/atlas/src/salt/landmark/artifact.rs index ac3e4462c2a..4120a0652f5 100644 --- a/libs/@local/graph/atlas/src/salt/landmark/artifact.rs +++ b/libs/@local/graph/atlas/src/salt/landmark/artifact.rs @@ -239,7 +239,7 @@ impl LandmarkSkeletonArchive { /// Returns the landmark count `M`. #[inline] #[must_use] - #[cfg(test)] // The landmark, file, and fit tests read the archive cross-module. + #[cfg(test)] pub(crate) fn landmarks(&self) -> u64 { self.file.landmarks() } @@ -247,7 +247,7 @@ impl LandmarkSkeletonArchive { /// Returns the corpus row count `N` the assignment covers. #[inline] #[must_use] - #[cfg(test)] // The landmark, file, and fit tests read the archive cross-module. + #[cfg(test)] pub(crate) fn rows(&self) -> u64 { self.file.rows() } @@ -263,7 +263,7 @@ impl LandmarkSkeletonArchive { /// Views the assignment: every node row's landmark ordinal, inside the landmark domain. #[must_use] - #[cfg(test)] // The landmark, file, and fit tests read the archive cross-module. + #[cfg(test)] pub(crate) fn assignment(&self) -> &IdSlice { IdSlice::from_raw( <[LandmarkOrdinal]>::ref_from_bytes(self.file.assignment().as_bytes()) @@ -272,7 +272,7 @@ impl LandmarkSkeletonArchive { } /// Views the layout coordinates, finite, keyed by landmark ordinal. - #[cfg(test)] // The landmark, file, and fit tests read the archive cross-module. + #[cfg(test)] #[must_use] pub(crate) fn coordinates(&self) -> &IdSlice { IdSlice::from_raw(self.file.coordinates()) diff --git a/libs/@local/graph/atlas/src/salt/landmark/layout.rs b/libs/@local/graph/atlas/src/salt/landmark/layout.rs index 1e151771006..4120aa56727 100644 --- a/libs/@local/graph/atlas/src/salt/landmark/layout.rs +++ b/libs/@local/graph/atlas/src/salt/landmark/layout.rs @@ -69,12 +69,8 @@ const DEFAULT_REPULSION_STRENGTH: NonNegative = non_negative!(1.0); /// The default number of vertices repelled per sampled edge. const DEFAULT_NEGATIVE_SAMPLE_RATE: NonZero = const { NonZero::new(5).unwrap() }; -/// Schedule settings for one layout, valid by construction. -// The defaults are the UMAP reference defaults, carried as unvalidated -// starting points; the release evaluation's layout criteria -// (trustworthiness, landmark rank correlation) revise them from -// evidence. -#[derive(Debug, Copy, Clone, PartialEq)] +/// Epoch, learning-rate and negative-sampling settings for one layout. +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct LayoutOptions { /// Optimization epochs, 500 by default. pub epochs: NonZero = DEFAULT_EPOCHS, diff --git a/libs/@local/graph/atlas/src/salt/landmark/quotient.rs b/libs/@local/graph/atlas/src/salt/landmark/quotient.rs index 03430fa0804..05a7b44773c 100644 --- a/libs/@local/graph/atlas/src/salt/landmark/quotient.rs +++ b/libs/@local/graph/atlas/src/salt/landmark/quotient.rs @@ -39,7 +39,7 @@ use crate::salt::semantic::{ const MAXIMUM_NEIGHBOURS: NonZero = const { NonZero::new(64).unwrap() }; /// Contraction settings. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct QuotientOptions { /// Strongest directed edges each landmark row keeps before union, 64 by default. // the unvalidated default bounds retained directions at M · 64 before union. Trustworthiness and landmark rank correlation supply the measurements for revising it. @@ -164,8 +164,8 @@ where .map(|(column, weight)| { #[expect( clippy::cast_possible_truncation, - reason = "a max-normalized finite weight lies in (0, 1], well \ - inside f32" + reason = "normalization bounds the ratio by one. Graph validation \ + rejects any zero produced by f32 underflow" )] let normalized = (weight / maximum) as f32; (column, normalized) diff --git a/libs/@local/graph/atlas/src/salt/landmark/select.rs b/libs/@local/graph/atlas/src/salt/landmark/select.rs index 7a00aca8614..a10892d0c0c 100644 --- a/libs/@local/graph/atlas/src/salt/landmark/select.rs +++ b/libs/@local/graph/atlas/src/salt/landmark/select.rs @@ -161,8 +161,10 @@ impl LandmarkCandidate { } } +const DEFAULT_RETAINED_FRACTION: UnitFraction = const { UnitFraction::new(0.25).unwrap() }; + /// Capacity and retention settings for one selection. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct SelectionOptions { /// The landmark capacity `M`. /// @@ -170,10 +172,9 @@ pub(crate) struct SelectionOptions { pub maximum_count: NonZero, /// Target fraction of prior landmarks, 0.25 by default. /// - /// Retention stabilizes generation-to-generation orientation. - // The default is an unvalidated starting point; the temporal-drift - // and landmark rank-correlation criteria revise it from evidence. - pub retained_fraction: UnitFraction = const { UnitFraction::new(0.25).unwrap() }, + /// Subgroup minimums take precedence. Available slots and prior candidates limit retention, while the final fill may exceed the target. Reusing landmarks encourages continuity between generations without fixing orientation. + // the default is an unvalidated starting point. Temporal drift and landmark rank correlation supply the measurements for revising it. + pub retained_fraction: UnitFraction = DEFAULT_RETAINED_FRACTION, /// Candidates per generator stream: the priority pass's seeding and parallel work unit. /// /// By default, uses 4,096 candidates. This value fixes which stream draws for each candidate. Equal-seed replay requires the same chunk size. @@ -439,8 +440,7 @@ where clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss, - reason = "the product of a bounded capacity and a unit-interval fraction is a small \ - non-negative integer count" + reason = "retention converts the ceiling of the computed f64 product to an integer target" )] #[inline] const fn retained_target(capacity: usize, retained_fraction: UnitFraction) -> usize { diff --git a/libs/@local/graph/atlas/src/salt/landmark/tests.rs b/libs/@local/graph/atlas/src/salt/landmark/tests.rs index 03d858dd2fd..b479747334e 100644 --- a/libs/@local/graph/atlas/src/salt/landmark/tests.rs +++ b/libs/@local/graph/atlas/src/salt/landmark/tests.rs @@ -29,7 +29,7 @@ use crate::{ dataset::PROJECTOR_DIMENSIONS, file::{WriteInto as _, landmark::read::LandmarkFile}, identity::NodeRowId, - math::{AffinityCurve, AlignedVecN, BoxedVecN, DPositive, NonNegative, Vec2, positive}, + math::{AffinityCurve, AlignedVecN, BoxedVecN, DPositive, NonNegative, Vec2, nz, positive}, salt::{ knn::{Embedding, NearestNeighboursIndex, Neighbour}, semantic::{SemanticGraph, SemanticMatrix}, @@ -150,7 +150,7 @@ fn selection_honors_subgroup_minimums() { &candidates, &[SubgroupMinimum { subgroup, - count: NonZero::new(5).expect("five is nonzero"), + count: nz!(5), }], options(8), rng(), @@ -240,7 +240,7 @@ fn selection_rejects_malformed_inputs() { }; let minimum = SubgroupMinimum { subgroup, - count: NonZero::new(1).expect("one is nonzero"), + count: nz!(1), }; assert_eq!( select(&candidates(4), &[minimum, minimum], options(10), rng()), @@ -261,7 +261,7 @@ fn selection_rejects_unsatisfiable_minimums() { &candidates(10), &[SubgroupMinimum { subgroup, - count: NonZero::new(2).expect("two is nonzero"), + count: nz!(2), }], options(5), rng(), @@ -283,7 +283,7 @@ fn selection_rejects_unsatisfiable_minimums() { &tagged, &[SubgroupMinimum { subgroup, - count: NonZero::new(6).expect("six is nonzero"), + count: nz!(6), }], options(5), rng(), @@ -311,14 +311,14 @@ fn selection_counts_rows_toward_every_minimum_they_satisfy() { dimension: SubgroupDimension::Language, value: 7, }, - count: NonZero::new(3).expect("three is nonzero"), + count: nz!(3), }, SubgroupMinimum { subgroup: Subgroup { dimension: SubgroupDimension::Community, value: 3, }, - count: NonZero::new(3).expect("three is nonzero"), + count: nz!(3), }, ]; @@ -349,7 +349,7 @@ fn selection_is_invariant_across_thread_counts() { dimension: SubgroupDimension::Language, value: 2, }, - count: NonZero::new(40).expect("forty is nonzero"), + count: nz!(40), }]; let single = in_pool(1, || select(&candidates, &minimums, options(128), rng())) @@ -653,9 +653,12 @@ fn quotient_keeps_only_the_strongest_neighbours() { let graph = semantic_from_edges( 8, &[ - (0, 2, 1.0), // L0 - L1, strongest - (1, 4, 0.5), // L0 - L2 - (1, 6, 0.25), // L0 - L3 + // L0 - L1, strongest + (0, 2, 1.0), + // L0 - L2 + (1, 4, 0.5), + // L0 - L3 + (1, 6, 0.25), ], ); let assignment = assignment_of(&[0, 0, 1, 1, 2, 2, 3, 3], 4); @@ -664,7 +667,7 @@ fn quotient_keeps_only_the_strongest_neighbours() { .quotient( &graph.view(), QuotientOptions { - maximum_neighbours: NonZero::new(1).expect("one is nonzero"), + maximum_neighbours: nz!(1), }, ) .expect("the fixture quotient has edges"); @@ -880,7 +883,7 @@ fn repulsion_widens_the_gap_between_disconnected_components() { &graph.view(), curve(), LayoutOptions { - epochs: NonZero::new(200).expect("test epoch budgets are nonzero"), + epochs: nz!(200), repulsion_strength: NonNegative::ZERO, .. }, diff --git a/libs/@local/graph/atlas/src/salt/lod/bench.rs b/libs/@local/graph/atlas/src/salt/lod/bench.rs index d2854b3b248..6a76aa76fe4 100644 --- a/libs/@local/graph/atlas/src/salt/lod/bench.rs +++ b/libs/@local/graph/atlas/src/salt/lod/bench.rs @@ -59,8 +59,8 @@ use super::{ use crate::{ file::morton::{Fenceposts, SEGMENTS}, identity::{BasePosition, ImportanceRank, NodeRowId, bench::KeyOrdinal}, - math::{FinitePointField, Vec2}, - morton::{Depth, MortonCell, MortonKey}, + math::{FinitePointField, Log2, Vec2, nz}, + morton::{Depth, MortonCell, MortonKey, MortonTile, Zoom}, random::{keyed_rng, uniform_below}, }; @@ -238,7 +238,7 @@ pub struct ChainAudit { #[derive(Debug)] pub struct VisibleCellPyramid { /// The shallowest depth the levels cover. - shallowest: u8, + shallowest: Depth, /// Ascending distinct cell indexes per depth, shallowest level first. levels: Box<[Box<[u64]>]>, } @@ -354,9 +354,9 @@ pub enum VisibleRankOrder { #[derive(Debug)] pub struct VisibleCascade { /// Visible points as key bits paired with their bucket depth, ascending by key. - points: Box<[(u64, u8)]>, + points: Box<[(u64, Depth)]>, /// The cut's span exponent `m`. - span: u8, + span: Log2, /// The deepest grid the cascade assigned over. deepest: Depth, } @@ -417,9 +417,9 @@ pub struct WalkBench { /// Every bucket's full segment in the base order. segments: Ranges, /// The cut's span exponent `m`. - span: u8, + span: Log2, /// The deepest tile zoom the schedule serves. - max_zoom: u8, + max_zoom: Zoom, /// Bit `r` set means row `r` is visible. visible: DenseBitSet, } @@ -528,11 +528,11 @@ impl WalkBench { /// # Panics /// /// This panics when `points` is zero or exceeds the `u32` row domain. - #[must_use] #[expect( clippy::cast_possible_truncation, - reason = "coordinates land in [-1, 1] and importances in [0, 1); f32 keeps the shape" + reason = "finite fixture coordinates and scores narrow to f32 before LOD normalization" )] + #[must_use] pub fn build(points: usize, seed: u64) -> Self { /// Per-cluster gaussian spread, widening with the cluster index. const SIGMAS: [f64; 8] = [0.02, 0.035, 0.05, 0.065, 0.08, 0.095, 0.11, 0.125]; @@ -549,11 +549,8 @@ impl WalkBench { let mut coordinates = Vec::with_capacity(points); for _ in 0..points { - let pick = usize::try_from(uniform_below( - &mut rng, - NonZero::new(10).expect("ten is nonzero"), - )) - .expect("draws below ten fit usize"); + let pick = usize::try_from(uniform_below(&mut rng, nz!(10))) + .expect("draws below ten fit usize"); let point = if pick < 8 { let (unit, angle) = (uniform(&mut rng), uniform(&mut rng)); let radius = SIGMAS[pick] * (-2.0 * unit.max(f64::MIN_POSITIVE).ln()).sqrt(); @@ -621,7 +618,7 @@ impl WalkBench { rank_of_position, position_of_key, key_order_of_position, - span: config.span.get(), + span: config.span, max_zoom: config.max_tile_depth, visible, } @@ -696,8 +693,8 @@ impl WalkBench { position_of_key, key_order_of_position, segments, - span, - max_zoom, + span: log2_of(span), + max_zoom: zoom_of(max_zoom), visible, } } @@ -753,9 +750,7 @@ impl WalkBench { clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss, - reason = "the expects name drawn depths and coordinates that lie on the grid by \ - construction; the quota is a hiding target and row counts sit far below the \ - mantissa width" + reason = "row counts fit u32 and are exact in f64. The quota truncates to whole rows" )] pub fn mask_clustered(&mut self, visible: f64, seed: u64) { let rows = self.row_of_position.len(); @@ -765,15 +760,12 @@ impl WalkBench { let mut count = 0; while count < quota { - let depth = 4 + u8::try_from(uniform_below( - &mut rng, - NonZero::new(4).expect("four is nonzero"), - )) - .expect("draws below four fit u8"); + let depth = + 4 + u8::try_from(uniform_below(&mut rng, nz!(4))).expect("draws below four fit u8"); let side = u64::from(1_u32 << depth); let bound = NonZero::new(side).expect("cell grids have nonzero sides"); let cell = MortonCell::new( - Depth::new(depth).expect("depths 4 through 7 lie within the key width"), + Depth::new(depth), u32::try_from(uniform_below(&mut rng, bound)).expect("draws stay below the side"), u32::try_from(uniform_below(&mut rng, bound)).expect("draws stay below the side"), ) @@ -830,20 +822,21 @@ impl WalkBench { /// This panics when the coordinate lies off the grid or beyond the schedule's deepest zoom. #[must_use] pub fn reached(&self, z: u8, x: u32, y: u32) -> Vec { + let z = zoom_of(z); assert!( z <= self.max_zoom, "the schedule serves zooms up to {}", self.max_zoom, ); let cell = cell_of(z, x, y); - let ranges = if z == 0 { + let ranges = if z == Zoom::MIN { self.segments.clone() } else { self.narrowed(cell) }; let mut keys = Vec::new(); - for range in &ranges[..=usize::from(z + self.span)] { + for range in &ranges[..=usize::from(self.cut_of(z).get())] { for position in range.clone() { if self .visible @@ -867,7 +860,7 @@ impl WalkBench { /// Panics when the coordinate lies off the grid, `z > 32`, or `z + span > 32`. #[must_use] pub fn scheduled(&self, z: u8, x: u32, y: u32) -> usize { - self.budget_of(z, x, y) + self.budget_of(zoom_of(z), x, y) } /// Returns the corpus row count. @@ -885,7 +878,7 @@ impl WalkBench { /// Returns the deepest tile zoom the schedule serves. #[must_use] pub const fn max_zoom(&self) -> u8 { - self.max_zoom + self.max_zoom.get() } /// Returns the cut's span exponent `m`. @@ -893,32 +886,28 @@ impl WalkBench { /// A tile at zoom `z` cuts at depth `z + m`. #[must_use] pub const fn span(&self) -> u8 { - self.span + self.span.get() } /// Returns the root-to-deepest descent path through the densest cells. /// - /// Each step descends into the child holding the most points before masking, so the path is one - /// fixture-determined column a whole mask sweep can share. - #[must_use] + /// Each step chooses the child holding the most points before masking. Quadrant order breaks + /// population ties. The path is independent of the mask and stops when every child is empty. #[expect( clippy::missing_panics_doc, reason = "the expects name children of on-grid cells, on the grid by construction" )] + #[must_use] pub fn descent(&self) -> Vec<(u8, u32, u32)> { let mut path = vec![(0_u8, 0_u32, 0_u32)]; let (mut x, mut y) = (0_u32, 0_u32); - for z in 1..=self.max_zoom { + for z in (Zoom::MIN..=self.max_zoom).skip(1) { let mut best = (0_usize, 0_u32, 0_u32); for quadrant in 0..4 { let (cx, cy) = (2 * x + (quadrant & 1), 2 * y + (quadrant >> 1)); - let cell = MortonCell::new( - Depth::new(z).expect("tile zooms lie within the key width"), - cx, - cy, - ) - .expect("children of an on-grid cell lie on the grid"); + let cell = MortonCell::new(Depth::from_zoom(z), cx, cy) + .expect("children of an on-grid cell lie on the grid"); let population = self .narrowed(cell) .iter() @@ -932,7 +921,7 @@ impl WalkBench { break; } (x, y) = (best.1, best.2); - path.push((z, x, y)); + path.push((z.get(), x, y)); } path @@ -945,6 +934,7 @@ impl WalkBench { /// This panics when the coordinate lies off the grid or beyond the schedule's deepest zoom. #[must_use] pub fn independent(&self, z: u8, x: u32, y: u32) -> Selection { + let z = zoom_of(z); let taken = DenseBitSet::new_empty(0); let mut delivered = Vec::new(); self.walk(z, x, y, &taken, &mut delivered, FillTarget::Scheduled) @@ -963,17 +953,18 @@ impl WalkBench { /// This panics when the coordinate lies off the grid or beyond the schedule's deepest zoom. #[must_use] pub fn chained(&self, z: u8, x: u32, y: u32) -> Selection { + let z = zoom_of(z); let mut taken = DenseBitSet::new_empty(self.codes.len()); let mut delivered = Vec::new(); let mut scanned = 0_usize; - for level in 0..z { - let shift = z - level; + for level in Zoom::MIN..z { + let (ancestor_x, ancestor_y) = ancestor_of(z, level, x, y); delivered.clear(); let ancestor = self.walk( level, - x >> shift, - y >> shift, + ancestor_x, + ancestor_y, &taken, &mut delivered, FillTarget::Scheduled, @@ -1005,6 +996,7 @@ impl WalkBench { /// This panics when the coordinate lies off the grid or beyond the schedule's deepest zoom. #[must_use] pub fn independent_delivery(&self, z: u8, x: u32, y: u32) -> Vec { + let z = zoom_of(z); let taken = DenseBitSet::new_empty(0); let mut delivered = Vec::new(); self.walk(z, x, y, &taken, &mut delivered, FillTarget::Scheduled); @@ -1022,16 +1014,17 @@ impl WalkBench { /// Ancestor exhaustion can return before checking the target address. #[must_use] pub fn chained_delivery(&self, z: u8, x: u32, y: u32) -> Vec { + let z = zoom_of(z); let mut taken = DenseBitSet::new_empty(self.codes.len()); let mut delivered = Vec::new(); - for level in 0..z { - let shift = z - level; + for level in Zoom::MIN..z { + let (ancestor_x, ancestor_y) = ancestor_of(z, level, x, y); delivered.clear(); let ancestor = self.walk( level, - x >> shift, - y >> shift, + ancestor_x, + ancestor_y, &taken, &mut delivered, FillTarget::Scheduled, @@ -1071,24 +1064,22 @@ impl WalkBench { } codes.sort_unstable(); - let deepest = self.max_zoom + self.span; - let mut levels = Vec::with_capacity(usize::from(self.max_zoom) + 1); - for depth in self.span..=deepest { - let depth = Depth::new(depth).expect("the schedule's cuts lie within the key width"); - let mut cells: Vec = Vec::new(); - for &bits in &codes { - let cell = MortonKey::from_bits(bits).prefix(depth); - if cells.last() != Some(&cell) { - cells.push(cell); + let shallowest = self.cut_of(Zoom::MIN); + let deepest = self.cut_of(self.max_zoom); + let levels = (shallowest..=deepest) + .map(|depth| { + let mut cells: Vec = Vec::new(); + for &bits in &codes { + let cell = MortonKey::from_bits(bits).prefix(depth); + if cells.last() != Some(&cell) { + cells.push(cell); + } } - } - levels.push(cells.into_boxed_slice()); - } + cells.into_boxed_slice() + }) + .collect(); - VisibleCellPyramid { - shallowest: self.span, - levels: levels.into_boxed_slice(), - } + VisibleCellPyramid { shallowest, levels } } /// Builds the Morton-ordered visible column over the whole corpus. @@ -1113,10 +1104,11 @@ impl WalkBench { /// enumerates a grid's occupied cells, at four bytes per visible row. The key of an entry comes /// from the corpus-wide base column the position indexes, and its importance rank from the /// corpus-wide rank column. - /// - /// # Panics - /// - /// This panics when the visible rows overrun the `u32` row domain. + #[expect( + clippy::missing_panics_doc, + reason = "construction bounds the base-column length by u32::MAX. Every base position \ + fits u32" + )] #[must_use] pub fn position_column(&self) -> Box<[u32]> { let mut positions: Vec = Vec::with_capacity(self.visible.count()); @@ -1147,12 +1139,13 @@ impl WalkBench { /// grid. The root ignores x and y. #[must_use] pub fn gather(&self, z: u8, x: u32, y: u32) -> VisibleColumn { + let z = zoom_of(z); assert!( z <= self.max_zoom, "the schedule serves zooms up to {}", self.max_zoom, ); - let ranges = if z == 0 { + let ranges = if z == Zoom::MIN { self.segments.clone() } else { self.narrowed(cell_of(z, x, y)) @@ -1245,8 +1238,7 @@ impl WalkBench { row_of_rank.sort_unstable_by_key(|&entry| ranks[entry]); let ranking = Ranking::from_row_of_rank(row_of_rank); - let deepest = Depth::new(self.max_zoom + self.span) - .expect("the schedule's cuts lie within the key width"); + let deepest = self.cut_of(self.max_zoom); let buckets = cascade::buckets(keyed, &ranking, deepest); let order = BaseOrder::new(keyed, &buckets, &ranking); @@ -1332,7 +1324,15 @@ impl WalkBench { /// worst-rank-first from a key-ordered list exposes exactly these neighbours. Therefore two /// sorts and one linear deletion pass recover the first-separation bucket assignment. /// - /// This panics when the visible rows overrun the `u32` row domain. + /// For a point with better-ranked neighbours, let D be the deepest shared grid. Its bucket is + /// min(D + 1, 32). The best-ranked point takes bucket zero. Equal keys share every grid and + /// later-ranked duplicates take the catch-all. This is the assignment computed by + /// [`Self::generation`]. + #[expect( + clippy::missing_panics_doc, + reason = "construction bounds the base-column length by u32::MAX. Visible rows form a \ + subset" + )] #[must_use] pub fn separated_generation(&self, layout: GenerationLayout) -> ServedGeneration { let (keys, positions, ranks) = self.visible_entries(); @@ -1378,7 +1378,7 @@ impl WalkBench { buckets[entry as usize] = if separated { Depth::MIN } else { - shared.saturating_add(1) + shared.saturating_add(Log2::ONE) }; let (low, high) = (before[at], after[at]); @@ -1740,14 +1740,13 @@ impl WalkBench { }; let ranking = Ranking::from_row_of_rank(row_of_rank); - let deepest = Depth::new(self.max_zoom + self.span) - .expect("the schedule's cuts lie within the key width"); + let deepest = self.cut_of(self.max_zoom); let buckets = cascade::buckets(keyed, &ranking, deepest); - let mut points: Vec<(u64, u8)> = keys + let mut points: Vec<(u64, Depth)> = keys .iter() .zip(buckets.iter()) - .map(|(key, bucket)| (key.to_bits(), bucket.get())) + .map(|(key, bucket)| (key.to_bits(), *bucket)) .collect(); points.sort_unstable(); @@ -1778,6 +1777,7 @@ impl WalkBench { y: u32, view: VisibleView<'_>, ) -> Selection { + let z = zoom_of(z); let mut delivered = Vec::new(); self.chain( rule, @@ -1811,6 +1811,7 @@ impl WalkBench { y: u32, view: VisibleView<'_>, ) -> Vec { + let z = zoom_of(z); let mut delivered = Vec::new(); self.chain( rule, @@ -1844,6 +1845,7 @@ impl WalkBench { y: u32, view: VisibleView<'_>, ) -> Vec { + let z = zoom_of(z); let mut delivered = Vec::new(); let mut inside = Vec::new(); self.chain( @@ -1873,7 +1875,7 @@ impl WalkBench { /// Panics when `z > 32` or the coordinate lies off its grid. #[must_use] pub fn occupied_cells(&self, z: u8, x: u32, y: u32, depth: Depth) -> HashSet { - let cell = cell_of(z, x, y); + let cell = cell_of(zoom_of(z), x, y); let mut cells = HashSet::new(); for (position, code) in self.codes.iter().enumerate() { if cell.contains(*code) @@ -1907,6 +1909,7 @@ impl WalkBench { y: u32, view: VisibleView<'_>, ) -> ChainAudit { + let z = zoom_of(z); let mut delivered = Vec::new(); let mut inside = Vec::new(); let chain = self.chain( @@ -1921,7 +1924,7 @@ impl WalkBench { }, ); - let cut = Depth::new(z + self.span).expect("the schedule's cuts lie within the key width"); + let cut = self.cut_of(z); let inherited_cells = self.distinct_cells(&inside, cut); inside.extend_from_slice(&delivered); let cumulative_cells = self.distinct_cells(&inside, cut); @@ -1955,7 +1958,7 @@ impl WalkBench { fn chain( &self, rule: FillRule, - z: u8, + z: Zoom, x: u32, y: u32, view: VisibleView<'_>, @@ -1967,26 +1970,27 @@ impl WalkBench { let pyramid = view.pyramid; let ChainBuffers { own, mut inside } = buffers; + let tile_depth = Depth::from_zoom(z); + let tile_entry = usize::from(tile_depth.get()); let key = cell_of(z, x, y).min_key(); let mut taken = DenseBitSet::new_empty(self.codes.len()); let mut delivered = Vec::new(); // Chain deliveries by the deepest chain level whose cell holds them: a position counts // inside every level at or above its entry. - let mut nesting = vec![0_usize; usize::from(z) + 1]; - // The cell rule re-reads the chain's positions at each level's own cut depth, so the - // history stays grouped by the deepest level holding them. + let mut nesting = vec![0_usize; tile_entry + 1]; + // grouping history by the deepest containing level permits each cell rule to resolve + // inherited positions at its own cut depth let mut history: Vec> = if rule == FillRule::CoverageCells { - vec![Vec::new(); usize::from(z) + 1] + vec![Vec::new(); tile_entry + 1] } else { Vec::new() }; let mut scanned = 0_usize; let mut spent = false; - for level in 0..z { - let shift = z - level; - let (ancestor_x, ancestor_y) = (x >> shift, y >> shift); - let inherited: usize = nesting[usize::from(level)..].iter().sum(); + for level in Zoom::MIN..z { + let (ancestor_x, ancestor_y) = ancestor_of(z, level, x, y); + let inherited: usize = nesting[usize::from(level.get())..].iter().sum(); let cut = self.cut_of(level); let covered = covered_of(rule, cell_of(level, ancestor_x, ancestor_y), cut, pyramid); let mut represented = HashSet::new(); @@ -2006,12 +2010,17 @@ impl WalkBench { for &position in &delivered { taken.insert(BasePosition::from_u32(position)); - let depth = self.codes[position as usize].shared_depth(key).get().min(z); - nesting[usize::from(depth)] += 1; + let entry = usize::from( + self.codes[position as usize] + .shared_depth(key) + .min(tile_depth) + .get(), + ); + nesting[entry] += 1; if rule == FillRule::CoverageCells { - history[usize::from(depth)].push(position); + history[entry].push(position); } - if depth == z + if entry == tile_entry && let Some(inside) = inside.as_deref_mut() { inside.push(position); @@ -2029,7 +2038,7 @@ impl WalkBench { } } - let inherited = nesting[usize::from(z)]; + let inherited = nesting[tile_entry]; let cut = self.cut_of(z); // The audit reports the tile's covered count under every rule; the coverage rules need it // for the target itself. @@ -2044,19 +2053,13 @@ impl WalkBench { let target = target_of(rule, inherited, covered, cut, &mut represented); if spent { - return ChainOutcome { - own: Selection { - budget: spent_budget(&target, self.budget_of(z, x, y), entry), - natural: 0, - tail: 0, - scanned, - }, - covered, - inherited, - spent, - refined: 0, - deepened: 0, + let own = Selection { + budget: spent_budget(&target, self.budget_of(z, x, y), entry), + natural: 0, + tail: 0, + scanned, }; + return ChainOutcome::walked(own, covered, inherited, spent); } let mut selection = self.walk(z, x, y, &taken, own, target); @@ -2064,14 +2067,7 @@ impl WalkBench { selection.budget = covered.saturating_sub(entry); } selection.scanned += scanned; - ChainOutcome { - own: selection, - covered, - inherited, - spent, - refined: 0, - deepened: 0, - } + ChainOutcome::walked(selection, covered, inherited, spent) } /// Delivers one tile's new cell representatives after recomputing its chain. @@ -2089,7 +2085,7 @@ impl WalkBench { fn rank_chain( &self, plan: RankPlan, - z: u8, + z: Zoom, x: u32, y: u32, column: &VisibleColumn, @@ -2104,13 +2100,13 @@ impl WalkBench { let mut scanned = 0_usize; let mut inherited = 0_usize; - for level in 0..z { - let shift = z - level; + for level in Zoom::MIN..z { + let (level_x, level_y) = ancestor_of(z, level, x, y); level_out.clear(); let step = self.rank_level( plan, RankLevel { - address: (level, x >> shift, y >> shift), + address: (level, level_x, level_y), column, represented: &represented, scratch: &mut scratch, @@ -2201,8 +2197,9 @@ impl WalkBench { }; // the cut grid is the minimum resolution, even when its target exceeds the budget while depth < Depth::MAX && cells.len() < range.len() { - let finer = - Depth::new(depth.get() + 1).expect("a depth below the maximum has a successor"); + let finer = depth + .checked_add(Log2::ONE) + .expect("a depth below the maximum has a successor"); column.split(range.clone(), finer, &mut scratch.finer); let wanted = needing(column, &scratch.finer, represented, finer); scanned += scratch.finer.len(); @@ -2216,8 +2213,9 @@ impl WalkBench { } if refinement.order != RefineOrder::Whole && depth < Depth::MAX { - let finer = - Depth::new(depth.get() + 1).expect("a depth below the maximum has a successor"); + let finer = depth + .checked_add(Log2::ONE) + .expect("a depth below the maximum has a successor"); let deepening = rank_deepen( (refinement.order, budget.saturating_sub(target)), column, @@ -2232,8 +2230,7 @@ impl WalkBench { } } - let finer = Depth::new(depth.get().saturating_add(1).min(Depth::MAX.get())) - .expect("the clamped successor lies within the key width"); + let finer = depth.saturating_add(Log2::ONE); let mut delivered = 0_usize; for (index, leaf) in cells.iter().enumerate() { if scratch.split.get(index).copied().unwrap_or(false) { @@ -2280,6 +2277,7 @@ impl WalkBench { y: u32, generation: &ServedGeneration, ) -> Selection { + let z = zoom_of(z); let mut delivered = Vec::new(); self.served_chain( served_plan(rule), @@ -2310,6 +2308,7 @@ impl WalkBench { y: u32, generation: &ServedGeneration, ) -> Vec { + let z = zoom_of(z); let mut delivered = Vec::new(); self.served_chain( served_plan(rule), @@ -2340,6 +2339,7 @@ impl WalkBench { y: u32, generation: &ServedGeneration, ) -> Vec { + let z = zoom_of(z); let mut delivered = Vec::new(); let mut inside = Vec::new(); self.served_chain( @@ -2378,6 +2378,7 @@ impl WalkBench { y: u32, generation: &ServedGeneration, ) -> ChainAudit { + let z = zoom_of(z); let mut delivered = Vec::new(); let mut inside = Vec::new(); let chain = self.served_chain( @@ -2392,7 +2393,7 @@ impl WalkBench { }, ); - let cut = Depth::new(z + self.span).expect("the schedule's cuts lie within the key width"); + let cut = self.cut_of(z); let inherited_cells = self.distinct_cells(&inside, cut); inside.extend_from_slice(&delivered); let cumulative_cells = self.distinct_cells(&inside, cut); @@ -2434,6 +2435,7 @@ impl WalkBench { depth: Depth, generation: &ServedGeneration, ) -> Vec { + let z = zoom_of(z); assert!( z <= self.max_zoom, "the schedule serves zooms up to {}", @@ -2441,7 +2443,7 @@ impl WalkBench { ); let cell = cell_of(z, x, y); assert!( - cell.depth().get() <= depth.get(), + cell.depth() <= depth, "a cell at depth {} holds no depth-{} cells", cell.depth().get(), depth.get(), @@ -2467,14 +2469,22 @@ impl WalkBench { /// Panics when `z` exceeds the schedule's maximum zoom or `additional_depth ≥ 64`. #[must_use] pub fn uniform_grid_depth(&self, z: u8, additional_depth: u8) -> Depth { + self.grid_depth(zoom_of(z), log2_of(additional_depth)) + } + + /// Returns the clamped sum of zoom, span and additional depth. + /// + /// # Panics + /// + /// Panics when `z` exceeds the schedule's maximum zoom. + fn grid_depth(&self, z: Zoom, additional_depth: Log2) -> Depth { assert!( z <= self.max_zoom, "the schedule serves zooms up to {}", self.max_zoom, ); - Depth::new(z) - .expect("the asserted zoom lies within the key width") - .saturating_add(self.span) + + z.saturating_depth(self.span) .saturating_add(additional_depth) } @@ -2490,27 +2500,18 @@ impl WalkBench { /// the corpus key column. fn uniform_positions( &self, - address: (u8, u32, u32), - depths: (u8, u8), + address: (Zoom, u32, u32), + grid: (Depth, Option), generation: &ServedGeneration, - cumulative: bool, ) -> Vec { let (z, x, y) = address; - let (additional_depth, previous_additional_depth) = depths; - let depth = self.uniform_grid_depth(z, additional_depth); - let ranges = if z == 0 { + let (depth, previous) = grid; + let ranges = if z == Zoom::MIN { generation.segments.clone() } else { generation.narrowed(cell_of(z, x, y), &generation.segments, &self.codes) }; - let first = if cumulative || z == 0 { - 0 - } else { - usize::from( - self.uniform_grid_depth(z - 1, previous_additional_depth) - .get(), - ) + 1 - }; + let first = previous.map_or(0, |previous| usize::from(previous.get()) + 1); let last = usize::from(depth.get()); if first > last { return Vec::new(); @@ -2552,12 +2553,13 @@ impl WalkBench { y: u32, generation: &ServedGeneration, ) -> Vec { - self.uniform_positions( - (z, x, y), - (additional_depth, additional_depth), - generation, - false, - ) + let additional_depth = log2_of(additional_depth); + let z = zoom_of(z); + let depth = self.grid_depth(z, additional_depth); + let previous = z + .shallower() + .map(|parent| self.grid_depth(parent, additional_depth)); + self.uniform_positions((z, x, y), (depth, previous), generation) } /// Accumulates a uniform grid inside one tile in generation-bucket order. @@ -2580,12 +2582,9 @@ impl WalkBench { y: u32, generation: &ServedGeneration, ) -> Vec { - self.uniform_positions( - (z, x, y), - (additional_depth, additional_depth), - generation, - true, - ) + let z = zoom_of(z); + let depth = self.grid_depth(z, log2_of(additional_depth)); + self.uniform_positions((z, x, y), (depth, None), generation) } /// Returns the grid depth of a public one-level refinement step. @@ -2598,12 +2597,30 @@ impl WalkBench { /// This panics when `z` lies beyond the schedule's deepest zoom. #[must_use] pub fn uniform_step_grid_depth(&self, refine_from_zoom: u8, z: u8) -> Depth { - let additional_depth = if z == self.max_zoom { - Depth::MAX.get().saturating_sub(z.saturating_add(self.span)) + self.step_grid_depth(threshold_of(refine_from_zoom), zoom_of(z)) + } + + /// Returns the cut, one finer grid after the threshold, or the terminal catch-all. + /// + /// # Panics + /// + /// Panics when `z` exceeds the schedule's maximum zoom. + fn step_grid_depth(&self, refine_from_zoom: Zoom, z: Zoom) -> Depth { + assert!( + z <= self.max_zoom, + "the schedule serves zooms up to {}", + self.max_zoom, + ); + if z == self.max_zoom { + return Depth::MAX; + } + + let additional_depth = if z >= refine_from_zoom { + Log2::ONE } else { - u8::from(z >= refine_from_zoom) + Log2::ZERO }; - self.uniform_grid_depth(z, additional_depth) + self.grid_depth(z, additional_depth) } /// Delivers one tile from a public one-level refinement step. @@ -2628,19 +2645,13 @@ impl WalkBench { y: u32, generation: &ServedGeneration, ) -> Vec { - let additional_depth = - self.uniform_step_grid_depth(refine_from_zoom, z).get() - z - self.span; - let previous_additional_depth = if z == 0 { - 0 - } else { - self.uniform_step_grid_depth(refine_from_zoom, z - 1).get() - (z - 1) - self.span - }; - self.uniform_positions( - (z, x, y), - (additional_depth, previous_additional_depth), - generation, - false, - ) + let refine_from_zoom = threshold_of(refine_from_zoom); + let z = zoom_of(z); + let depth = self.step_grid_depth(refine_from_zoom, z); + let previous = z + .shallower() + .map(|parent| self.step_grid_depth(refine_from_zoom, parent)); + self.uniform_positions((z, x, y), (depth, previous), generation) } /// Accumulates a public one-level refinement step inside one tile. @@ -2662,14 +2673,9 @@ impl WalkBench { y: u32, generation: &ServedGeneration, ) -> Vec { - let additional_depth = - self.uniform_step_grid_depth(refine_from_zoom, z).get() - z - self.span; - self.uniform_positions( - (z, x, y), - (additional_depth, additional_depth), - generation, - true, - ) + let z = zoom_of(z); + let depth = self.step_grid_depth(threshold_of(refine_from_zoom), z); + self.uniform_positions((z, x, y), (depth, None), generation) } /// Delivers one tile behind its chain out of a served generation. @@ -2685,7 +2691,7 @@ impl WalkBench { fn served_chain( &self, plan: RankPlan, - z: u8, + z: Zoom, x: u32, y: u32, generation: &ServedGeneration, @@ -2703,9 +2709,8 @@ impl WalkBench { let mut scanned = 0_usize; let mut inherited = 0_usize; - for level in 0..z { - let shift = z - level; - let (level_x, level_y) = (x >> shift, y >> shift); + for level in Zoom::MIN..z { + let (level_x, level_y) = ancestor_of(z, level, x, y); ranges = generation.narrowed(cell_of(level, level_x, level_y), &ranges, &self.codes); level_out.clear(); let step = self.served_level( @@ -2733,12 +2738,12 @@ impl WalkBench { } } merge_ascending(&mut represented, &mut merged, &run); - // Every later level's extent lies inside the next one, so a key outside it can never - // sit in a cell a later level asks about. - retain_cell( - &mut represented, - cell_of(level + 1, x >> (shift - 1), y >> (shift - 1)), - ); + // Later extents are subsets of the next extent. Their represented cells can contain + // only keys inside it. Therefore keys outside the next extent can be removed from the + // chain history. + let deeper = level.deeper().expect("a chain level lies above the tile"); + let (deeper_x, deeper_y) = ancestor_of(z, deeper, x, y); + retain_cell(&mut represented, cell_of(deeper, deeper_x, deeper_y)); } ranges = generation.narrowed(cell, &ranges, &self.codes); @@ -2821,8 +2826,7 @@ impl WalkBench { scratch.split.clear(); scratch.split.resize(cells, false); - let finer = Depth::new(depth.get().saturating_add(1).min(Depth::MAX.get())) - .expect("the clamped successor lies within the key width"); + let finer = depth.saturating_add(Log2::ONE); let mut deepened = 0_usize; if let RankPlan::Refined(refinement) = plan && refinement.order != RefineOrder::Whole @@ -2866,7 +2870,7 @@ impl WalkBench { fn served_grid( &self, plan: RankPlan, - address: (u8, u32, u32), + address: (Zoom, u32, u32), extent: &ServedExtent<'_>, cut: Depth, ) -> (Depth, usize, usize) { @@ -2884,8 +2888,9 @@ impl WalkBench { let mut covered = reach(&extent.ranges, cut); let mut reads = 0_usize; while depth < Depth::MAX && covered < population { - let finer = - Depth::new(depth.get() + 1).expect("a depth below the maximum has a successor"); + let finer = depth + .checked_add(Log2::ONE) + .expect("a depth below the maximum has a successor"); let reached = covered + extent.ranges[usize::from(finer.get())].len(); reads += extent.held.len(); if reached - distinct_prefixes(extent.held, finer) > budget { @@ -3046,9 +3051,14 @@ impl WalkBench { probes } - /// Returns the level's cut depth. - const fn cut_of(&self, z: u8) -> Depth { - Depth::new(z + self.span).expect("the schedule's cuts lie within the key width") + /// Returns the level's zoom plus the schedule span. + /// + /// # Panics + /// + /// Panics when the sum exceeds [`Depth::MAX`]. + const fn cut_of(&self, z: Zoom) -> Depth { + z.depth(self.span) + .expect("the schedule's cuts lie within the key width") } /// Collects the cells the chain's deliveries inside the level's cell occupy at `cut`. @@ -3064,7 +3074,7 @@ impl WalkBench { &self, rule: FillRule, history: &[Vec], - z: u8, + z: Zoom, cut: Depth, represented: &mut HashSet, ) { @@ -3072,7 +3082,7 @@ impl WalkBench { return; } - for level in history.iter().skip(usize::from(z)) { + for level in history.iter().skip(usize::from(z.get())) { for &position in level { represented.insert(self.codes[position as usize].prefix(cut)); } @@ -3094,16 +3104,20 @@ impl WalkBench { /// Returns the tile's scheduled count before masking. /// - /// A function of the corpus and the tile address alone: no mask enters it. - fn budget_of(&self, z: u8, x: u32, y: u32) -> usize { + /// Depends on the corpus and tile address, with no mask input. The maximum zoom is not checked. + /// + /// # Panics + /// + /// Panics on an off-grid coordinate or a cut beyond the key width. + fn budget_of(&self, z: Zoom, x: u32, y: u32) -> usize { let cell = cell_of(z, x, y); - let ranges = if z == 0 { + let ranges = if z == Zoom::MIN { self.segments.clone() } else { self.narrowed(cell) }; - let cut = usize::from(z + self.span); - let natural = if z == 0 { 0..=cut } else { cut..=cut }; + let cut = usize::from(self.cut_of(z).get()); + let natural = if z == Zoom::MIN { 0..=cut } else { cut..=cut }; ranges[natural].iter().map(ExactSizeIterator::len).sum() } @@ -3115,16 +3129,17 @@ impl WalkBench { /// This panics when the coordinate lies off the grid or beyond the schedule's deepest zoom. #[must_use] pub fn crowding(&self, z: u8, x: u32, y: u32) -> Crowding { + let z = zoom_of(z); let mut taken = DenseBitSet::new_empty(self.codes.len()); let mut delivered = Vec::new(); - for level in 0..z { - let shift = z - level; + for level in Zoom::MIN..z { + let (ancestor_x, ancestor_y) = ancestor_of(z, level, x, y); delivered.clear(); self.walk( level, - x >> shift, - y >> shift, + ancestor_x, + ancestor_y, &taken, &mut delivered, FillTarget::Scheduled, @@ -3160,7 +3175,7 @@ impl WalkBench { /// domain. fn walk( &self, - z: u8, + z: Zoom, x: u32, y: u32, taken: &DenseBitSet, @@ -3174,15 +3189,15 @@ impl WalkBench { ); let cell = cell_of(z, x, y); - let ranges = if z == 0 { + let ranges = if z == Zoom::MIN { self.segments.clone() } else { self.narrowed(cell) }; - let cut = usize::from(z + self.span); + let cut = usize::from(self.cut_of(z).get()); // The root's schedule is buckets 0..=m whole. Deeper tiles schedule bucket z + m alone. - let natural_buckets = if z == 0 { 0..=cut } else { cut..=cut }; + let natural_buckets = if z == Zoom::MIN { 0..=cut } else { cut..=cut }; let scheduled: usize = ranges[natural_buckets.clone()] .iter() .map(ExactSizeIterator::len) @@ -3336,11 +3351,7 @@ impl VisibleCellPyramid { /// Returns the pyramid's depths, shallowest first. #[must_use] pub fn depths(&self) -> impl IntoIterator { - let shallowest = self.shallowest; - (0..self.levels.len()).map(move |offset| { - let offset = u8::try_from(offset).expect("the levels span at most the key width"); - Depth::new(shallowest + offset).expect("every level's depth lies within the key width") - }) + (self.shallowest..=Depth::MAX).take(self.levels.len()) } /// Returns the bytes the cell levels occupy. @@ -3360,7 +3371,7 @@ impl VisibleCellPyramid { fn level(&self, depth: Depth) -> &[u64] { let offset = depth .get() - .checked_sub(self.shallowest) + .checked_sub(self.shallowest.get()) .expect("the pyramid holds the depth"); &self.levels[usize::from(offset)] } @@ -3683,14 +3694,19 @@ impl VisibleCascade { /// Panics when `z > 32`, the coordinate lies off its grid or `z + span > 32`. #[must_use] pub fn schedule(&self, z: u8, x: u32, y: u32) -> usize { - let cut = z + self.span; + let z = zoom_of(z); + let cut = z + .depth(self.span) + .expect("the cut lies within the key width"); self.within(cell_of(z, x, y)) .iter() - .filter( - |&&(_, bucket)| { - if z == 0 { bucket <= cut } else { bucket == cut } - }, - ) + .filter(|&&(_, bucket)| { + if z == Zoom::MIN { + bucket <= cut + } else { + bucket == cut + } + }) .count() } @@ -3704,7 +3720,10 @@ impl VisibleCascade { /// Panics when `z > 32`, the coordinate lies off its grid or `z + span > 32`. #[must_use] pub fn covered(&self, z: u8, x: u32, y: u32) -> usize { - let cut = z + self.span; + let z = zoom_of(z); + let cut = z + .depth(self.span) + .expect("the cut lies within the key width"); self.within(cell_of(z, x, y)) .iter() .filter(|&&(_, bucket)| bucket <= cut) @@ -3728,11 +3747,7 @@ impl VisibleCascade { .iter() .map(|&(bits, _)| MortonKey::from_bits(bits)) .collect(); - let buckets: Vec = self - .points - .iter() - .map(|&(_, bucket)| Depth::new(bucket).expect("buckets lie within the key width")) - .collect(); + let buckets: Vec = self.points.iter().map(|&(_, bucket)| bucket).collect(); cascade::verify_coverage( IdSlice::::from_raw(&keys), @@ -3743,7 +3758,7 @@ impl VisibleCascade { } /// Returns the points inside `cell`. - fn within(&self, cell: MortonCell) -> &[(u64, u8)] { + fn within(&self, cell: MortonCell) -> &[(u64, Depth)] { let low = cell.min_key().to_bits(); let high = cell.max_key().to_bits(); let start = self.points.partition_point(|&(bits, _)| bits < low); @@ -3779,6 +3794,20 @@ struct ChainOutcome { deepened: usize, } +impl ChainOutcome { + /// Records a bucket-walk outcome with zero refinement counts. + const fn walked(own: Selection, covered: usize, inherited: usize, spent: bool) -> Self { + Self { + own, + covered, + inherited, + spent, + refined: 0, + deepened: 0, + } + } +} + /// One rank-representative level's outcome. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] struct RankStep { @@ -3839,7 +3868,7 @@ struct ServedScratch { #[derive(Debug)] struct ServedLevel<'level> { /// The level's tile address, `(z, x, y)`. - address: (u8, u32, u32), + address: (Zoom, u32, u32), /// The generation the level reads its grid and representatives out of. generation: &'level ServedGeneration, /// The level extent's per-bucket ranges of the generation. @@ -3871,7 +3900,7 @@ struct RankScratch { #[derive(Debug)] struct RankLevel<'level> { /// The level's tile address, `(z, x, y)`. - address: (u8, u32, u32), + address: (Zoom, u32, u32), /// The visible view the level scans. column: &'level VisibleColumn, /// The chain's deliveries so far, ascending by key. @@ -4335,18 +4364,59 @@ fn target_of( } } +/// Validates a raw tile zoom against the key width. +/// +/// # Panics +/// +/// This panics when the zoom lies beyond the key width. +const fn zoom_of(z: u8) -> Zoom { + Zoom::new(z).expect("the zoom lies within the key width") +} + +/// Validates a raw grid exponent against the shift width. +/// +/// # Panics +/// +/// This panics when the exponent lies at or above the `u64` shift width. +const fn log2_of(levels: u8) -> Log2 { + Log2::new(levels).expect("the exponent lies below the shift width") +} + +/// Converts a refinement threshold, clamping values above the zoom domain. +/// +/// Values beyond the zoom domain clamp to [`Zoom::MAX`]. No regular served zoom reaches that +/// threshold before the terminal catch-all takes over, making it a way to request no intermediate +/// refinement. +const fn threshold_of(zoom: u8) -> Zoom { + match Zoom::new(zoom) { + Some(zoom) => zoom, + None => Zoom::MAX, + } +} + /// Returns the cell at `(z, x, y)`. /// /// # Panics /// -/// This panics when the zoom lies beyond the key width or the coordinate off the zoom's grid. -const fn cell_of(z: u8, x: u32, y: u32) -> MortonCell { - MortonCell::new( - Depth::new(z).expect("tile zooms lie within the key width"), +/// Panics when the coordinate lies off the zoom's grid. +const fn cell_of(z: Zoom, x: u32, y: u32) -> MortonCell { + MortonCell::new(Depth::from_zoom(z), x, y).expect("the coordinate lies on the zoom's grid") +} + +/// Returns the tile's ancestor coordinates on `level`'s grid. +/// +/// # Panics +/// +/// This panics when `level` lies deeper than `z`. +const fn ancestor_of(z: Zoom, level: Zoom, x: u32, y: u32) -> (u32, u32) { + let tile = MortonTile { + z: Depth::from_zoom(z), x, y, - ) - .expect("the coordinate lies on the zoom's grid") + } + .ancestor(Depth::from_zoom(level)); + + (tile.x, tile.y) } /// Returns every bucket's full segment as scan offsets. @@ -4371,6 +4441,7 @@ mod tests { use core::ops::RangeInclusive; use std::collections::HashSet; + use hashql_core::id::Id as _; use proptest::{ prop_assert, prop_assert_eq, prop_oneof, property_test, sample::Index, @@ -4379,9 +4450,9 @@ mod tests { use super::{ ChainAudit, DotBudget, FillRule, GenerationLayout, RefineOrder, Refinement, - ServedGeneration, VisibleRankOrder, VisibleView, WalkBench, cell_of, + ServedGeneration, VisibleRankOrder, VisibleView, WalkBench, }; - use crate::morton::{Depth, MortonKey}; + use crate::morton::{Depth, MortonCell, MortonKey}; /// The corpus scale the module's exhaustive checks run at. const POINTS: usize = 8_000; @@ -4417,6 +4488,15 @@ mod tests { corpus(POINTS, SEED, clustered, visible) } + /// Returns the cell at `(z, x, y)` through the probe's untyped boundary. + /// + /// # Panics + /// + /// Panics when `z > 32` or the coordinate lies off its grid. + fn cell_of(z: u8, x: u32, y: u32) -> MortonCell { + super::cell_of(super::zoom_of(z), x, y) + } + /// Builds a corpus of `points` rows from `seed` and masks it with the same seed. /// /// A clustered mask requires `visible` in `[0, 1]` as in [`WalkBench::mask_clustered`]. @@ -4527,7 +4607,7 @@ mod tests { fn buckets_by_position(generation: &ServedGeneration, positions: usize) -> Vec { let mut buckets = vec![Depth::MAX; positions]; for (bucket, segment) in generation.segments.iter().enumerate() { - let depth = Depth::new( + let depth = Depth::try_new( u8::try_from(bucket).expect("the segment table lies in the depth domain"), ) .expect("every segment names a valid depth"); @@ -4553,7 +4633,12 @@ mod tests { .. }) => bench.scheduled(z, x, y).max( bench - .occupied_cells(z, x, y, Depth::new(z + bench.span()).expect("a valid cut")) + .occupied_cells( + z, + x, + y, + Depth::try_new(z + bench.span()).expect("a valid cut"), + ) .len(), ), FillRule::Refined(Refinement { @@ -4776,8 +4861,8 @@ mod tests { let (codes, _, _) = bench.columns(); for (z, x, y) in tiles(&bench) { - let cut = Depth::new(z + bench.span()).expect("a valid cut"); - for depth in [cut, Depth::new(cut.get() + 2).expect("a valid grid")] { + let cut = Depth::try_new(z + bench.span()).expect("a valid cut"); + for depth in [cut, Depth::try_new(cut.get() + 2).expect("a valid grid")] { let served = bench.served_representatives(z, x, y, depth, &generation); let cells: HashSet = served .iter() @@ -4883,7 +4968,7 @@ mod tests { } assert!( strict > 0, - "no point moved shallower, so the check pins nothing" + "masking should move at least one visible fixture point to a shallower bucket" ); } @@ -4941,8 +5026,8 @@ mod tests { for rule in refinements(DotBudget::Scheduled) { assert!( served_interference(rule, false, 0.5).is_some(), - "{rule:?} passed the noninterference check over the served engine, so the check \ - no longer separates a hidden-independent rule from a leaking one", + "scheduled-budget {rule:?} should serve different rows after removing hidden rows \ + in this fixture", ); } } @@ -5202,8 +5287,8 @@ mod tests { for rule in refinements(DotBudget::Scheduled) { assert!( served_interference(rule, false, 0.5).is_some(), - "{rule:?} passed beside the public grid, so the identity check no longer \ - separates the known-bad rule", + "scheduled-budget {rule:?} should serve different rows after removing hidden rows \ + in this fixture", ); } } @@ -5220,7 +5305,7 @@ mod tests { let generation = bench.indexed_generation(GenerationLayout::Inline); for z in 0..=3_u8 { - let window_depth = Depth::new(z + 2).expect("the audit windows fit the key"); + let window_depth = Depth::try_new(z + 2).expect("the audit windows fit the key"); let windows = 1_usize << (2 * u32::from(window_depth.get())); let counts = |positions: Vec| { let mut counts = vec![0_usize; windows]; @@ -5328,8 +5413,8 @@ mod tests { let (z, x, y) = tile(&bench, pick); let audit = bench.served_audit(rule, z, x, y, &generation); let delivered = bench.served_cumulative_delivery(rule, z, x, y, &generation); - let cut = Depth::new(z + bench.span()).expect("the cut lies in the key width"); - let grid = Depth::new(cut.get() + audit.refined) + let cut = Depth::try_new(z + bench.span()).expect("the cut lies in the key width"); + let grid = Depth::try_new(cut.get() + audit.refined) .expect("the delivered grid lies within the key width"); for depth in [cut, grid] { @@ -5404,8 +5489,7 @@ mod tests { for rule in rules { assert!( interference(rule, false, 0.5).is_some(), - "{rule:?} passed the noninterference check, so the check no longer separates a \ - hidden-independent rule from a leaking one", + "{rule:?} should deliver different rows after removing hidden rows in this fixture", ); } } @@ -5450,7 +5534,8 @@ mod tests { assert_eq!(column.len(), bench.visible_rows()); for (z, x, y) in bench.descent() { - let cut = Depth::new(z + bench.span()).expect("the cut lies in the key width"); + let cut = + Depth::try_new(z + bench.span()).expect("the cut lies in the key width"); assert_eq!( pyramid.count(cell_of(z, x, y), cut), cascade.covered(z, x, y), @@ -5583,8 +5668,8 @@ mod tests { let (z, x, y) = tile(&bench, pick); let audit = bench.audit(rule, z, x, y, view); let delivered = bench.cumulative_delivery(rule, z, x, y, view); - let cut = Depth::new(z + bench.span()).expect("the cut lies within the key width"); - let grid = Depth::new(cut.get() + audit.refined) + let cut = Depth::try_new(z + bench.span()).expect("the cut lies within the key width"); + let grid = Depth::try_new(cut.get() + audit.refined) .expect("the delivered grid lies within the key width"); for depth in [cut, grid] { @@ -5634,7 +5719,7 @@ mod tests { let mut overruns = 0_usize; for (z, x, y) in tiles(&bench) { let audit = bench.audit(rule, z, x, y, view); - let cut = Depth::new(z + bench.span()).expect("the cut lies within the key width"); + let cut = Depth::try_new(z + bench.span()).expect("the cut lies within the key width"); let shown: HashSet = bench .cumulative_delivery(rule, z, x, y, view) .iter() @@ -5651,7 +5736,7 @@ mod tests { assert!( overruns > 0, - "the small budget never bound, so this check pins nothing", + "covering the cut cells should overrun the small budget on at least one fixture tile", ); } diff --git a/libs/@local/graph/atlas/src/salt/lod/cascade.rs b/libs/@local/graph/atlas/src/salt/lod/cascade.rs index 2c748bbd36a..71a140d0112 100644 --- a/libs/@local/graph/atlas/src/salt/lod/cascade.rs +++ b/libs/@local/graph/atlas/src/salt/lod/cascade.rs @@ -14,7 +14,7 @@ use hashql_core::{ use super::rank::Ranking; use crate::{ - identity::ImportanceRank, + math::Log2, morton::{Depth, MortonKey}, }; @@ -70,7 +70,8 @@ pub(crate) fn buckets( // the same invariant. Therefore one pass per depth suffices to preserve coverage and one // delivered representative per cell below the catch-all. for depth in 0..=deepest.get() { - let depth = Depth::new(depth).expect("every depth at or below `deepest` is a valid depth"); + let depth = + Depth::try_new(depth).expect("every depth at or below `deepest` is a valid depth"); seen.clear(); for &row in ranking.row_of_rank.iter() { @@ -115,10 +116,10 @@ pub(crate) fn buckets( /// result and scratch storage. Each point enters and leaves the stack at most once. Sorting the /// input is a separate cost. #[must_use] -pub(crate) fn separation_buckets_in( +pub(crate) fn separation_buckets_in( points: &[T], key: impl Fn(&T) -> MortonKey, - rank: impl Fn(&T) -> ImportanceRank, + rank: impl Fn(&T) -> P, alloc: A, scratch: S, ) -> Box<[Depth], A> { @@ -129,7 +130,8 @@ pub(crate) fn separation_buckets_in( "the points must ascend by (key, rank)", ); - let separation = |left: &T, right: &T| key(left).shared_depth(key(right)).saturating_add(1); + let separation = + |left: &T, right: &T| key(left).shared_depth(key(right)).saturating_add(Log2::ONE); // The stack holds the points whose nearest better-ranked right neighbour is still unseen, ranks // ascending from bottom to top. The point that pops an entry is that neighbour, and @@ -168,10 +170,10 @@ pub(crate) fn separation_buckets_in( /// Uses the global allocator for both output and scratch storage. Input requirements and the bucket /// formula are those of [`separation_buckets_in`]. #[must_use] -pub(crate) fn separation_buckets( +pub(crate) fn separation_buckets( points: &[T], key: impl Fn(&T) -> MortonKey, - rank: impl Fn(&T) -> ImportanceRank, + rank: impl Fn(&T) -> P, ) -> Box<[Depth]> { separation_buckets_in(points, key, rank, Global, Global) } @@ -203,7 +205,7 @@ pub(crate) struct CoverageGap { #[cfg(any(test, feature = "bench"))] #[expect( clippy::panic_in_result_fn, - reason = "mismatched row counts are a programmer error, not a coverage gap" + reason = "mismatched row counts violate the assignment's input contract" )] pub(crate) fn verify_coverage( keys: &IdSlice, @@ -220,7 +222,8 @@ pub(crate) fn verify_coverage( let mut covered = HashSet::new(); for depth in 0..=deepest.get() { - let depth = Depth::new(depth).expect("every depth at or below `deepest` is a valid depth"); + let depth = + Depth::try_new(depth).expect("every depth at or below `deepest` is a valid depth"); covered.clear(); covered.extend( diff --git a/libs/@local/graph/atlas/src/salt/lod/quad.rs b/libs/@local/graph/atlas/src/salt/lod/quad.rs index c5da4430dac..d1cf0b95fc5 100644 --- a/libs/@local/graph/atlas/src/salt/lod/quad.rs +++ b/libs/@local/graph/atlas/src/salt/lod/quad.rs @@ -66,7 +66,7 @@ impl core::fmt::Display for QuadError { Self::Schedule { config } => write!( fmt, "the schedule needs {} + {} subdivisions where a 64-bit Morton key resolves {}", - config.max_tile_depth, + config.max_tile_depth.get(), config.span.get(), Depth::MAX.get(), ), @@ -219,12 +219,8 @@ impl WriteInto for QuadTree { } } -/// The measurements of one quadtree build. -/// -/// What the manifest records so that data rather than taste drives a revision of the configuration. -/// These are build census numbers rather than evidence, and the metadata's `Evidence` section holds -/// the admission checks. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +/// Node, depth, and type-entry counts for calibrating a quadtree schedule. +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct QuadMeasurements { /// Nodes in the table. pub nodes: u64, diff --git a/libs/@local/graph/atlas/src/salt/lod/stage.rs b/libs/@local/graph/atlas/src/salt/lod/stage.rs index 915992dfd16..5343203602a 100644 --- a/libs/@local/graph/atlas/src/salt/lod/stage.rs +++ b/libs/@local/graph/atlas/src/salt/lod/stage.rs @@ -12,7 +12,7 @@ use std::io; -use hashql_core::id::{IdSlice, IdVec}; +use hashql_core::id::{Id as _, IdSlice, IdVec}; use super::{ cascade, key, @@ -30,7 +30,7 @@ use crate::{ identity::{BasePosition, ImportanceRank, NodeRowId}, integrity::{Sha256, Sha256Digest, Writer}, math::{Bounds2, FinitePointField, Log2, Vec2}, - morton::{Depth, MortonKey}, + morton::{Depth, MortonKey, Zoom}, }; /// The fixed frame every wire coordinate lives in. @@ -42,12 +42,16 @@ pub(crate) const WIRE_FRAME: Bounds2 = Bounds2::new(Vec2::new(-1.0, -1.0), Vec2: /// The default [`LodConfig::span`]. const DEFAULT_SPAN: Log2 = Log2::new(6).expect("6 lies below the shift width"); +/// The default [`LodConfig::max_tile_depth`]. +const DEFAULT_ZOOM: Zoom = Zoom::new(18).expect("18 lies within the key width"); /// Configuration of the level-of-detail schedule. /// -/// Both values are starting points that no measurement has validated. The [`LodMeasurements`] of -/// real generations revise them, and the manifest records the configuration a generation used. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +/// By default the cut spans 64 cells per tile axis and serves tile zooms through 18, placing the +/// catch-all grid at depth 24. These are unvalidated starting values. Compare [`LodMeasurements`] +/// across real generations when revising them, retaining each generation's configuration with its +/// measurements. +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct LodConfig { /// Cells per tile axis of the delivery cut, as its base-2 log. /// @@ -57,10 +61,8 @@ pub(crate) struct LodConfig { pub span: Log2 = DEFAULT_SPAN, /// The deepest tile zoom the schedule serves. /// - /// The deepest cascade grid sits at `max_tile_depth + span`, which the configured defaults put - /// at depth 24 - the resolution where `f32` coordinates in the wire frame stop separating - /// points. - pub max_tile_depth: u8 = 18, + /// By default this is 18. The deepest cascade grid is `max_tile_depth + span`, depth 24 with both defaults. Its cells have axis width 2⁻²³ in the wire frame. Distinct `f32` coordinates can still share a cell at this or any supported grid depth. + pub max_tile_depth: Zoom = DEFAULT_ZOOM, } const impl Default for LodConfig { @@ -78,11 +80,11 @@ impl LodConfig { /// maximum tile zoom zₘₐₓ and span m, a buildable schedule requires zₘₐₓ + m ≤ 32. #[must_use] pub(crate) const fn deepest(self) -> Option { - let Some(sum) = self.span.get().checked_add(self.max_tile_depth) else { + let Some(sum) = self.span.get().checked_add(self.max_tile_depth.get()) else { return None; }; - Depth::new(sum) + Depth::try_new(sum) } } @@ -106,7 +108,7 @@ impl core::fmt::Display for LodError { Self::Schedule { config } => write!( fmt, "the schedule needs {} + {} subdivisions where a 64-bit Morton key resolves {}", - config.max_tile_depth, + config.max_tile_depth.get(), config.span.get(), Depth::MAX.get(), ), @@ -116,7 +118,7 @@ impl core::fmt::Display for LodError { ), Self::Frame => write!( fmt, - "the coordinates hold no rows, so no world frame exists", + "the coordinates hold no rows to fit a world frame from", ), } } @@ -124,18 +126,47 @@ impl core::fmt::Display for LodError { impl core::error::Error for LodError {} -/// The measurements of one lod build. +mod serde_bucket_histogram { + + use serde::{Deserialize as _, Deserializer, Serialize as _, Serializer, de::Error as _}; + + use crate::file::morton::SEGMENTS; + + pub(super) fn serialize( + histogram: &[u64; SEGMENTS], + serializer: S, + ) -> Result + where + S: Serializer, + { + histogram.as_slice().serialize(serializer) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<[u64; SEGMENTS], D::Error> + where + D: Deserializer<'de>, + { + // we could use an `[MaybeUninit; SEGMENTS]` here instead, but it's not worth the effort. + let histogram = Vec::::deserialize(deserializer)?; + + <[u64; SEGMENTS]>::try_from(histogram).map_err(|histogram| { + D::Error::invalid_length(histogram.len(), &"one bucket per segment") + }) + } +} + +/// Bucket populations and spatial counts for calibrating an LOD schedule. /// -/// What the manifest records so that data rather than taste drives a revision of the configuration. -/// These are build census numbers rather than evidence, and the metadata's `Evidence` section holds -/// the admission checks. -#[derive(Debug, Copy, Clone, PartialEq)] +/// These statistics describe the finished columns. They do not independently verify coverage or the +/// tile-delivery cap. +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct LodMeasurements { /// The world frame the normalization mapped onto the wire frame. pub world: Bounds2, /// Points per bucket. /// /// The tail calibrates `max_tile_depth`. + #[serde(with = "serde_bucket_histogram")] pub bucket_histogram: [u64; SEGMENTS], /// Points in the deepest bucket. /// @@ -313,7 +344,7 @@ impl Lod { pub(crate) fn measurements(&self, config: LodConfig) -> LodMeasurements { let deepest = config .deepest() - .expect("the structure was built under this configuration"); + .expect("the structure's build used this configuration"); // sorted segment codes put each cell's population in one consecutive equal-prefix group let catch_all = self.segment_codes(deepest); @@ -323,9 +354,9 @@ impl Lod { // each bucket uses its first tile zoom; the root's buckets are scanned separately let mut max_tile_delta = 0; for bucket in 0..=deepest.get() { - let tile = Depth::new(bucket.saturating_sub(config.span.get())) + let tile = Depth::try_new(bucket.saturating_sub(config.span.get())) .expect("a tile depth never exceeds its bucket's own depth"); - let bucket = Depth::new(bucket).expect("buckets never exceed the deepest grid"); + let bucket = Depth::try_new(bucket).expect("buckets never exceed the deepest grid"); let delta = largest_prefix_group(self.segment_codes(bucket), tile); max_tile_delta = max_tile_delta.max(delta); } diff --git a/libs/@local/graph/atlas/src/salt/lod/tests.rs b/libs/@local/graph/atlas/src/salt/lod/tests.rs index 05a41d66956..31e4b25bb0a 100644 --- a/libs/@local/graph/atlas/src/salt/lod/tests.rs +++ b/libs/@local/graph/atlas/src/salt/lod/tests.rs @@ -12,10 +12,10 @@ use super::{ stage::{Lod, LodConfig, LodError}, }; use crate::{ - file::quad::Node, + file::{ArtifactFile as _, quad::Node}, identity::{BasePosition, ImportanceRank, NodeRowId, OntologyRowId}, math::{Bounds2, FinitePointField, Log2, Vec2}, - morton::{Depth, MortonCell, MortonKey}, + morton::{Depth, MortonCell, MortonKey, Zoom}, postgres::id::ArchivedEntityId, }; @@ -88,7 +88,7 @@ fn ranking_of(row_of_rank: &[u32]) -> Ranking { /// /// Panics above [`Depth::MAX`]. fn depth(value: u8) -> Depth { - Depth::new(value).expect("test depths lie within the documented domain") + Depth::try_new(value).expect("test depths lie within the documented domain") } /// Checks a literal span exponent. @@ -100,6 +100,15 @@ fn log2(value: u8) -> Log2 { Log2::new(value).expect("test spans lie below the shift width") } +/// Checks a literal tile zoom. +/// +/// # Panics +/// +/// Panics above [`Zoom::MAX`]. +fn zoom(value: u8) -> Zoom { + Zoom::new(value).expect("test zooms lie within the key width") +} + #[test] fn rank_orders_by_importance_then_priority_then_tiebreak() { // Rows: importance dominates, priority splits the first tie, the @@ -443,19 +452,19 @@ fn lod_config_carries_the_key_width_bound() { // the default grid reaches depth 24, with wire-axis cell width 2⁻²³ let config = LodConfig::default(); assert_eq!(config.span.get(), 6); - assert_eq!(config.max_tile_depth, 18); + assert_eq!(config.max_tile_depth, zoom(18)); assert_eq!(config.deepest(), Some(depth(24))); // The inequality z_max + m ≤ 32 binds exactly at the key width. let at_width = LodConfig { span: log2(6), - max_tile_depth: 26, + max_tile_depth: zoom(26), }; assert_eq!(at_width.deepest(), Some(depth(32))); let beyond = LodConfig { span: log2(6), - max_tile_depth: 27, + max_tile_depth: zoom(27), }; assert_eq!(beyond.deepest(), None); @@ -500,7 +509,7 @@ fn hand_stage() -> (Lod, LodConfig) { let ids = identities(4); let config = LodConfig { span: log2(1), - max_tile_depth: 1, + max_tile_depth: zoom(1), }; let lod = Lod::build( @@ -732,7 +741,7 @@ fn built_columns_uphold_the_contract_laws( let ids = identities(rows.len() as u128); let config = LodConfig { span: log2(span_log2), - max_tile_depth, + max_tile_depth: zoom(max_tile_depth), }; let inputs = rank_inputs(&importance, &priority, &ids).expect("the fixture columns agree"); @@ -890,7 +899,7 @@ fn quad_build_gathers_types_through_the_base_order() { let ids = identities(4); let config = LodConfig { span: log2(1), - max_tile_depth: 1, + max_tile_depth: zoom(1), }; let lod = Lod::build( finite(&coordinates), @@ -938,14 +947,14 @@ fn quad_build_rejects_what_no_tree_covers() { &hand_types(), LodConfig { span: log2(32), - max_tile_depth: 1, + max_tile_depth: zoom(1), }, ) .expect_err("a schedule beyond the key width must not build"), QuadError::Schedule { config: LodConfig { span: log2(32), - max_tile_depth: 1, + max_tile_depth: zoom(1), }, }, ); @@ -965,7 +974,7 @@ fn quad_build_rejects_what_no_tree_covers() { &hand_types(), LodConfig { span: log2(1), - max_tile_depth: 0, + max_tile_depth: zoom(0), }, ) .expect_err("a mismatched configuration must not build"), @@ -1005,11 +1014,9 @@ fn quad_tree_round_trips_through_the_quad_file() { let file = QuadFile::open(&path).expect("the written file reopens"); assert_eq!(file.nodes(), tree.nodes.as_slice()); - // The child tile locates, its pruned siblings do not, and its type set reads back. - let quadrant = MortonCell::new(depth(1), 0, 0).expect("the quadrant exists"); - assert_eq!(file.locate(quadrant), Some(1)); - let sibling = MortonCell::new(depth(1), 1, 0).expect("the quadrant exists"); - assert_eq!(file.locate(sibling), None); + // The written child pointers and type set match the fitted tree. + assert_eq!(file.nodes()[0].children()[0], Some(1)); + assert_eq!(file.nodes()[0].children()[1], None); let stored: Vec = file.type_set(1).iter().map(|id| id.get()).collect(); assert_eq!(stored, [2, 5, 9]); } @@ -1035,7 +1042,7 @@ fn quad_trees_uphold_the_contract_laws( .collect(); let config = LodConfig { span: log2(span_log2), - max_tile_depth, + max_tile_depth: zoom(max_tile_depth), }; let inputs = rank_inputs(&importance, &priority, &ids).expect("the fixture columns agree"); @@ -1220,7 +1227,7 @@ fn oracle_natural_buckets(points: &[(MortonKey, ImportanceRank)], deepest: Depth } best.map_or(Depth::MIN, |shared| { - depth(shared).saturating_add(1).min(deepest) + depth(shared).saturating_add(Log2::ONE).min(deepest) }) }) .collect() diff --git a/libs/@local/graph/atlas/src/salt/mod.rs b/libs/@local/graph/atlas/src/salt/mod.rs index 0dbe56b6806..cfdac5d819e 100644 --- a/libs/@local/graph/atlas/src/salt/mod.rs +++ b/libs/@local/graph/atlas/src/salt/mod.rs @@ -7,7 +7,7 @@ pub(crate) mod adjacency; pub(crate) mod embedding; -mod file; +pub(crate) mod file; pub(crate) mod fit; pub(crate) mod importance; pub(crate) mod knn; diff --git a/libs/@local/graph/atlas/src/salt/policy/annotation/assembly/mod.rs b/libs/@local/graph/atlas/src/salt/policy/annotation/assembly/mod.rs index 8f86eb2105b..8e5f15bac6d 100644 --- a/libs/@local/graph/atlas/src/salt/policy/annotation/assembly/mod.rs +++ b/libs/@local/graph/atlas/src/salt/policy/annotation/assembly/mod.rs @@ -82,6 +82,7 @@ use super::{AnnotationCorpus, CardIdentity, HoldoutClass}; use crate::{ dataset::card, identity::CardRow, + math::{PositiveUnitFraction, positive_unit_fraction}, progress::Progress, salt::{ embedding::{ @@ -120,12 +121,12 @@ const NEAR_DUPLICATE_CEILING_FRACTION: f64 = 0.25; const CARD_LANGUAGE: &str = "en"; /// Assembly settings. -#[derive(Debug, Copy, Clone, PartialEq, Default)] +#[derive(Debug, Copy, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] pub(crate) struct AssemblyConfig { /// The largest fraction of the trained rows one validation group may hold, in `(0, 1]`. /// - /// Beyond it, subdivision relaxes the group's weakest axes. - pub maximum_group_fraction: f64 = 0.1, + /// Beyond it, subdivision relaxes the group's weakest axes. By default, the fraction is `0.1`. + pub maximum_group_fraction: PositiveUnitFraction = positive_unit_fraction!(0.1), } /// Assembling the corpus into a training set failed. diff --git a/libs/@local/graph/atlas/src/salt/policy/annotation/assembly/tests.rs b/libs/@local/graph/atlas/src/salt/policy/annotation/assembly/tests.rs index a826169a206..6c61512fb6e 100644 --- a/libs/@local/graph/atlas/src/salt/policy/annotation/assembly/tests.rs +++ b/libs/@local/graph/atlas/src/salt/policy/annotation/assembly/tests.rs @@ -11,10 +11,10 @@ use serde_json::{Value, json}; use super::{AssemblyConfig, AssemblyError, HoldoutClass, assemble}; use crate::{ dataset::CANONICAL_DIMENSIONS, - file::array::ArrayFile, + file::{ArtifactFile as _, array::ArrayFile}, identity::CardRow, integrity::{Sha256, Update as _}, - math::BoxedVecN, + math::{BoxedVecN, PositiveUnitFraction, positive_unit_fraction}, progress::NoProgress, salt::{ embedding::{CardEmbedder, EmbedderFingerprint}, @@ -419,7 +419,7 @@ async fn assembly_smooths_groups_and_counts_the_fixture_corpus() { &corpus, &ProgrammedEmbedder, AssemblyConfig { - maximum_group_fraction: 1.0, + maximum_group_fraction: positive_unit_fraction!(1.0), .. }, &NoProgress, @@ -587,9 +587,7 @@ async fn language_the_template_does_not_render_is_rejected() { ) .await .expect_err("the template renders English corpora"); - assert!( - matches!(error, AssemblyError::Language { card: 0, ref language } if &**language == "de"), - ); + assert_matches!(error, AssemblyError::Language { card: 0, ref language } if &**language == "de"); } /// Assembles a document's cards under the given group budget. @@ -600,7 +598,8 @@ async fn assemble_under(cards: &[Value], maximum_group_fraction: f64) -> super:: &corpus, &ProgrammedEmbedder, AssemblyConfig { - maximum_group_fraction, + maximum_group_fraction: PositiveUnitFraction::new(maximum_group_fraction) + .expect("maximum_group_fraction must be in (0, 1]"), .. }, &NoProgress, diff --git a/libs/@local/graph/atlas/src/salt/policy/artifact/mod.rs b/libs/@local/graph/atlas/src/salt/policy/artifact/mod.rs index 969a6372f28..7a1cc4bd0e3 100644 --- a/libs/@local/graph/atlas/src/salt/policy/artifact/mod.rs +++ b/libs/@local/graph/atlas/src/salt/policy/artifact/mod.rs @@ -9,8 +9,8 @@ not(test), expect( dead_code, - reason = "the mapped policy-table re-read is the designed reader, the consumer is work in \ - progress" + reason = "the archive is the policy file's designed reader, and nothing outside the tests \ + opens it" ) )] use core::{error::Error, fmt, mem::offset_of}; diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/mod.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/mod.rs index 0d3d1525b3e..0b17014dfba 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/mod.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/mod.rs @@ -50,10 +50,6 @@ use crate::{ }; mod applicability; -pub(crate) use self::solver::{ - NewtonStage, PreparationError, PreparationSettings, SolverConfig, SolverConfigError, - SolverFailure, -}; mod calibration; mod objective; pub(crate) mod regularization; @@ -62,6 +58,11 @@ pub(crate) mod solver; #[cfg(test)] mod tests; +pub(crate) use self::solver::{ + NewtonStage, PreparationError, PreparationSettings, SolverConfig, SolverConfigError, + SolverFailure, SolverOptions, +}; + /// A training input violated the corpus contract. #[derive(Debug, Copy, Clone, PartialEq)] pub(crate) enum TrainingSetError { @@ -130,13 +131,13 @@ pub(crate) enum FitError { #[expect( clippy::use_debug, - reason = "the wrapped verdicts are typed vocabulary; their variant names are the message" + reason = "the wrapped verdicts are typed vocabulary, and their variant names are the message" )] impl fmt::Display for FitError { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Config(error) => { - write!(fmt, "the solver configuration is invalid: {error:?}") + write!(fmt, "the solver configuration is invalid: {error}") } Self::FoldCount { folds } => { write!(fmt, "{folds} validation folds cannot hold anything out") @@ -158,6 +159,12 @@ impl fmt::Display for FitError { impl Error for FitError {} +const impl From for FitError { + fn from(error: SolverConfigError) -> Self { + Self::Config(error) + } +} + /// One soft label, vote weight, and indivisible validation group. /// /// The group digest names the finest unit a validation split never divides. Corpus assembly @@ -284,19 +291,45 @@ impl<'training> TrainingSet<'training> { } } +/// Raw classifier-fit knobs admitted by [`FitConfig::new`]. +#[derive(Debug, Copy, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] +pub(crate) struct FitOptions { + /// The bounded trust-region exact-Newton solver configuration, preparation knobs included. + /// + /// By default, uses [`SolverOptions`]' defaults. + pub solver: SolverOptions = SolverOptions { .. }, + /// Grouped cross-validation fold count. At least 2. + /// + /// By default, this is `5`. + pub folds: usize = 5, + /// Fold-assignment seed. + /// + /// By default, this is `0`. + pub seed: u64 = 0, +} + +impl TryFrom for FitConfig { + type Error = FitError; + + fn try_from(value: FitOptions) -> Result { + Self::new(value) + } +} + /// Solver and grouped-validation settings. /// /// The solver defaults are the deployment configuration, with the regularization strength selected /// per fit ([`regularization`]). The out-of-fold metrics in [`FitEvidence`] judge the selected /// configuration. -#[derive(Debug, Copy, Clone, PartialEq, Default)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "FitOptions")] pub(crate) struct FitConfig { /// The bounded trust-region exact-Newton solver configuration, preparation knobs included. - pub solver: SolverConfig = SolverConfig { .. }, + pub solver: SolverConfig, /// Grouped cross-validation fold count. At least 2. - pub folds: usize = 5, + folds: usize, /// Fold-assignment seed. - pub seed: u64 = 0, + seed: u64, } impl FitConfig { @@ -304,16 +337,43 @@ impl FitConfig { /// /// # Errors /// - /// Returns [`FitError::Config`] for a solver configuration violating a cross-field constraint - /// and [`FitError::FoldCount`] for fewer than two folds. - pub(crate) fn validate(self) -> Result<(), FitError> { - self.solver.validate().map_err(FitError::Config)?; - - if self.folds < 2 { - return Err(FitError::FoldCount { folds: self.folds }); + /// Returns [`FitError`] for invalid solver options or fewer than two folds. + pub(crate) const fn new( + FitOptions { + solver, + folds, + seed, + }: FitOptions, + ) -> Result { + let solver = SolverConfig::new(solver)?; + + if folds < 2 { + return Err(FitError::FoldCount { folds }); } - Ok(()) + Ok(Self { + solver, + folds, + seed, + }) + } + + /// Returns the grouped cross-validation fold count. + pub(crate) const fn folds(&self) -> usize { + self.folds + } + + /// Returns the fold-assignment seed. + pub(crate) const fn seed(&self) -> u64 { + self.seed + } +} + +const impl Default for FitConfig { + fn default() -> Self { + const DEFAULT: FitConfig = FitConfig::new(FitOptions { .. }).ok().unwrap(); + + DEFAULT } } @@ -365,8 +425,6 @@ pub(crate) fn fit( config: FitConfig, progress: &P, ) -> Result { - config.validate()?; - let folds = grouped_folds(training.rows(), config.folds, config.seed)?; progress.classifier_started(config.folds); diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/regularization.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/regularization.rs index 17e2d2b9fd1..a1e34467ab6 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/regularization.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/regularization.rs @@ -22,31 +22,27 @@ use rayon::iter::{IntoParallelIterator as _, ParallelIterator as _}; use super::{FitConfig, FitError, FoldedTraining, calibration, objective, solver::WorkCounters}; use crate::{ identity::CardRow, - math::{DNonNegative, DPositive}, + math::{DNonNegative, DPositive, d_positive}, progress::Progress, salt::policy::GeometryClass, }; /// The candidate strengths, ascending. pub(super) const CANDIDATES: [DPositive; 13] = { - const fn strength(value: f64) -> DPositive { - DPositive::new(value).expect("the candidate is finite and positive") - } - [ - strength(1.0e-3), - strength(3.0e-3), - strength(1.0e-2), - strength(3.0e-2), - strength(0.1), - strength(0.3), - strength(1.0), - strength(3.0), - strength(10.0), - strength(30.0), - strength(100.0), - strength(300.0), - strength(1.0e3), + d_positive!(1.0e-3), + d_positive!(3.0e-3), + d_positive!(1.0e-2), + d_positive!(3.0e-2), + d_positive!(0.1), + d_positive!(0.3), + d_positive!(1.0), + d_positive!(3.0), + d_positive!(10.0), + d_positive!(30.0), + d_positive!(100.0), + d_positive!(300.0), + d_positive!(1.0e3), ] }; @@ -107,10 +103,12 @@ impl FoldedTraining<'_> { .flat_map(|candidate| (0..config.folds).map(move |fold| (candidate, fold))) .collect(); + let span = tracing::Span::current(); // Rayon's collect preserves input order: candidate-major, fold-minor. let models: Vec<_> = pairs .into_par_iter() .map(|(candidate, fold)| { + let _entered = span.enter(); let mut candidate_config = config; candidate_config.solver.preparation.regularization = CANDIDATES[candidate]; let (parameters, _) = @@ -120,7 +118,7 @@ impl FoldedTraining<'_> { progress.classifier_fold_completed(fold); } - Ok(parameters) + Ok::<_, FitError>(parameters) }) .collect::>()?; diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/config.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/config.rs index 126af6c841e..2e193e9a932 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/config.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/config.rs @@ -1,10 +1,10 @@ //! Validated solver-loop configuration. //! -//! `SolverOptions` carries every knob of the trust-region exact-Newton loop: the radius domain, +//! [`SolverOptions`] carries every knob of the trust-region exact-Newton loop: the radius domain, //! shrink and expansion factors, acceptance thresholds, convergence tolerances, ulp counts, and the //! inclusive outer-iteration budget. Per-field domains are carried by the field types, the //! validated scalars of [`math`](crate::math) and the non-zero integers of [`core::num`]. -//! `SolverConfig::new` checks the radius and acceptance-threshold orderings that no field type +//! [`SolverConfig::new`] checks the radius and acceptance-threshold orderings that no field type //! can carry alone. A [`SolverConfig`] value is therefore in domain. [`PreparationSettings`] //! supplies the preparation-side knobs within the same configuration. //! @@ -13,10 +13,13 @@ //! iteration structure itself, at a small fixed number of evaluations and traversals per outer //! iteration. -use core::num::NonZero; +use core::{fmt, num::NonZero}; use super::prepare::PreparationSettings; -use crate::math::{DNonNegative, DPositive, GreaterThanOne, OpenUnitFraction}; +use crate::math::{ + DNonNegative, DPositive, GreaterThanOne, OpenUnitFraction, d_non_negative, d_positive, + greater_than_one, nz, open_unit_fraction, +}; /// A cross-field constraint failed. /// @@ -41,62 +44,170 @@ pub(crate) enum SolverConfigError { }, } +impl fmt::Display for SolverConfigError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RadiusDomain { + minimum, + initial, + maximum, + } => write!( + fmt, + "trust radii must satisfy minimum <= initial <= maximum: minimum={minimum}, \ + initial={initial}, maximum={maximum}", + ), + Self::AcceptanceThresholds { accept, expand } => write!( + fmt, + "acceptance thresholds must satisfy accept < expand: accept={accept}, \ + expand={expand}", + ), + } + } +} + +impl core::error::Error for SolverConfigError {} + +/// Default preparation knobs. +const DEFAULT_PREPARATION: PreparationSettings = PreparationSettings { .. }; + +/// Default smallest admissible trust radius `Δ_min`. +const DEFAULT_RADIUS_MINIMUM: DPositive = d_positive!(1.0e-8); + +/// Default starting trust radius `Δ_initial`. +const DEFAULT_RADIUS_INITIAL: DPositive = DPositive::ONE; + +/// Default largest admissible trust radius `Δ_max`. +const DEFAULT_RADIUS_MAXIMUM: DPositive = d_positive!(1.0e4); + +/// Default radius contraction factor on rejection. +const DEFAULT_SHRINK_FACTOR: OpenUnitFraction = open_unit_fraction!(0.25); + +/// Default radius growth factor on an expanded boundary step. +const DEFAULT_EXPANSION_FACTOR: GreaterThanOne = greater_than_one!(2.0); + +/// Default acceptance ratio threshold `η_accept`. +const DEFAULT_ETA_ACCEPT: OpenUnitFraction = open_unit_fraction!(0.1); + +/// Default expansion ratio threshold `η_expand`. +const DEFAULT_ETA_EXPAND: OpenUnitFraction = open_unit_fraction!(0.75); + +/// Default gradient-certificate tolerance relative to the initial scaled gradient norm. +const DEFAULT_RELATIVE_SCALED_GRADIENT_TOLERANCE: OpenUnitFraction = open_unit_fraction!(1.0e-6); + +/// Default absolute floor of the gradient certificate. +const DEFAULT_ABSOLUTE_SCALED_GRADIENT_TOLERANCE: DNonNegative = d_non_negative!(1.0e-10); + +/// Default objective-resolution width in ulps. +const DEFAULT_OBJECTIVE_RESOLUTION_ULPS: NonZero = nz!(4); + +/// Default dogleg Cauchy-curvature guard width in ulps. +const DEFAULT_CURVATURE_GUARD_ULPS: NonZero = nz!(16); + +/// Default inclusive maximum of started outer iterations. +const DEFAULT_MAXIMUM_OUTER_ITERATIONS: NonZero = nz!(500); + +/// Raw solver knobs admitted by [`SolverConfig::new`]. +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub(crate) struct SolverOptions { + /// Preparation knobs: regularization, target-sum tolerance, and curvature floor. + /// + /// By default, uses [`PreparationSettings`]' defaults. + pub preparation: PreparationSettings = DEFAULT_PREPARATION, + /// Smallest admissible trust radius `Δ_min`. + /// + /// By default, this is `1e-8`. + pub radius_minimum: DPositive = DEFAULT_RADIUS_MINIMUM, + /// Starting trust radius `Δ_initial`. + /// + /// By default, this is `1`. + pub radius_initial: DPositive = DEFAULT_RADIUS_INITIAL, + /// Largest admissible trust radius `Δ_max`. + /// + /// By default, this is `1e4`. + pub radius_maximum: DPositive = DEFAULT_RADIUS_MAXIMUM, + /// Radius contraction factor on rejection. + /// + /// By default, this is `0.25`. + pub shrink_factor: OpenUnitFraction = DEFAULT_SHRINK_FACTOR, + /// Radius growth factor on an expanded boundary step. + /// + /// By default, this is `2`. + pub expansion_factor: GreaterThanOne = DEFAULT_EXPANSION_FACTOR, + /// Acceptance ratio threshold `η_accept`. Equality accepts. + /// + /// By default, this is `0.1`. + pub eta_accept: OpenUnitFraction = DEFAULT_ETA_ACCEPT, + /// Expansion ratio threshold `η_expand`. Equality expands a tagged boundary step. + /// + /// By default, this is `0.75`. + pub eta_expand: OpenUnitFraction = DEFAULT_ETA_EXPAND, + /// Gradient-certificate tolerance relative to the initial scaled gradient norm. + /// + /// By default, this is `1e-6`. + pub relative_scaled_gradient_tolerance: OpenUnitFraction = + DEFAULT_RELATIVE_SCALED_GRADIENT_TOLERANCE, + /// Absolute floor of the gradient certificate. Zero disables it. + /// + /// By default, this is `1e-10`. + pub absolute_scaled_gradient_tolerance: DNonNegative = + DEFAULT_ABSOLUTE_SCALED_GRADIENT_TOLERANCE, + /// Objective-resolution width in ulps of the accepted objective's spacing. + /// + /// By default, this is `4`. + pub objective_resolution_ulps: NonZero = DEFAULT_OBJECTIVE_RESOLUTION_ULPS, + /// Dogleg Cauchy-curvature guard width in ulps of the gradient-scale product `‖g‖·‖Hg‖`. + /// + /// By default, this is `16`. + pub curvature_guard_ulps: NonZero = DEFAULT_CURVATURE_GUARD_ULPS, + /// Inclusive maximum of started outer iterations. + /// + /// By default, this is `500`. + pub maximum_outer_iterations: NonZero = DEFAULT_MAXIMUM_OUTER_ITERATIONS, +} + +impl TryFrom for SolverConfig { + type Error = SolverConfigError; + + fn try_from(options: SolverOptions) -> Result { + Self::new(options) + } +} + /// Trust-region exact-Newton loop configuration. /// -/// Every field carries a default; `SolverConfig { .. }` is the deployment configuration and -/// satisfies [`validate`](Self::validate). The outer-iteration cap sits well beyond the measured -/// demand at annotation-corpus scale, so termination is by tolerance and the budget terminal -/// reports as a failure. -#[derive(Debug, Copy, Clone, PartialEq)] +/// [`SolverOptions`] supplies every field. [`SolverConfig::default`] is the deployment +/// configuration. Its outer-iteration cap lies well beyond the measured demand at +/// annotation-corpus scale. Termination is therefore by tolerance, and the budget terminal reports +/// as a failure. +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "SolverOptions")] pub(crate) struct SolverConfig { /// Preparation knobs: regularization, target-sum tolerance, and curvature floor. - pub preparation: PreparationSettings = PreparationSettings { .. }, + pub preparation: PreparationSettings, /// Smallest admissible trust radius `Δ_min`. - pub radius_minimum: DPositive = const { - DPositive::new(1.0e-8).expect("the radius floor is positive") - }, + radius_minimum: DPositive, /// Starting trust radius `Δ_initial`. - pub radius_initial: DPositive = DPositive::ONE, + radius_initial: DPositive, /// Largest admissible trust radius `Δ_max`. - pub radius_maximum: DPositive = const { - DPositive::new(1.0e4).expect("the radius cap is positive") - }, + radius_maximum: DPositive, /// Radius contraction factor on rejection. - pub shrink_factor: OpenUnitFraction = const { - OpenUnitFraction::new(0.25).expect("a quarter is interior") - }, + shrink_factor: OpenUnitFraction, /// Radius growth factor on an expanded boundary step. - pub expansion_factor: GreaterThanOne = const { - GreaterThanOne::new(2.0).expect("doubling expands") - }, - /// Acceptance ratio threshold `η_accept`; equality accepts. - pub eta_accept: OpenUnitFraction = const { - OpenUnitFraction::new(0.1).expect("a tenth is interior") - }, - /// Expansion ratio threshold `η_expand`; equality expands a tagged boundary step. - pub eta_expand: OpenUnitFraction = const { - OpenUnitFraction::new(0.75).expect("three quarters is interior") - }, + expansion_factor: GreaterThanOne, + /// Acceptance ratio threshold `η_accept`. Equality accepts. + eta_accept: OpenUnitFraction, + /// Expansion ratio threshold `η_expand`. Equality expands a tagged boundary step. + eta_expand: OpenUnitFraction, /// Gradient-certificate tolerance relative to the initial scaled gradient norm. - pub relative_scaled_gradient_tolerance: OpenUnitFraction = const { - OpenUnitFraction::new(1.0e-6).expect("the relative tolerance is interior") - }, + relative_scaled_gradient_tolerance: OpenUnitFraction, /// Absolute floor of the gradient certificate. Zero disables it. - pub absolute_scaled_gradient_tolerance: DNonNegative = const { - DNonNegative::new(1.0e-10).expect("the absolute floor is non-negative") - }, + absolute_scaled_gradient_tolerance: DNonNegative, /// Objective-resolution width in ulps of the accepted objective's spacing. - pub objective_resolution_ulps: NonZero = const { - NonZero::::new(4).expect("four is nonzero") - }, + objective_resolution_ulps: NonZero, /// Dogleg Cauchy-curvature guard width in ulps of the gradient-scale product `‖g‖·‖Hg‖`. - pub curvature_guard_ulps: NonZero = const { - NonZero::::new(16).expect("sixteen is nonzero") - }, + curvature_guard_ulps: NonZero, /// Inclusive maximum of started outer iterations. - pub maximum_outer_iterations: NonZero = const { - NonZero::::new(500).expect("five hundred is nonzero") - }, + maximum_outer_iterations: NonZero, } impl SolverConfig { @@ -104,28 +215,56 @@ impl SolverConfig { /// /// # Errors /// - /// Returns the [`SolverConfigError`] of the first violated ordering, in declared field order. - #[expect(clippy::missing_const_for_fn, reason = "false positive")] - pub(crate) fn validate(&self) -> Result<(), SolverConfigError> { - let radius_ordered = self.radius_minimum <= self.radius_initial - && self.radius_initial <= self.radius_maximum; + /// Returns [`SolverConfigError`] for misordered radii or acceptance thresholds. + pub(crate) const fn new( + SolverOptions { + preparation, + radius_minimum, + radius_initial, + radius_maximum, + shrink_factor, + expansion_factor, + eta_accept, + eta_expand, + relative_scaled_gradient_tolerance, + absolute_scaled_gradient_tolerance, + objective_resolution_ulps, + curvature_guard_ulps, + maximum_outer_iterations, + }: SolverOptions, + ) -> Result { + let radius_ordered = radius_minimum <= radius_initial && radius_initial <= radius_maximum; if !radius_ordered { return Err(SolverConfigError::RadiusDomain { - minimum: self.radius_minimum, - initial: self.radius_initial, - maximum: self.radius_maximum, + minimum: radius_minimum, + initial: radius_initial, + maximum: radius_maximum, }); } - if self.eta_accept >= self.eta_expand { + if eta_accept >= eta_expand { return Err(SolverConfigError::AcceptanceThresholds { - accept: self.eta_accept, - expand: self.eta_expand, + accept: eta_accept, + expand: eta_expand, }); } - Ok(()) + Ok(Self { + preparation, + radius_minimum, + radius_initial, + radius_maximum, + shrink_factor, + expansion_factor, + eta_accept, + eta_expand, + relative_scaled_gradient_tolerance, + absolute_scaled_gradient_tolerance, + objective_resolution_ulps, + curvature_guard_ulps, + maximum_outer_iterations, + }) } /// Derives the gradient-certificate threshold from the initial scaled gradient norm. @@ -140,4 +279,63 @@ impl SolverConfig { self.absolute_scaled_gradient_tolerance .max(self.relative_scaled_gradient_tolerance * initial_norm) } + + /// Returns the smallest admissible trust radius. + pub(crate) const fn radius_minimum(&self) -> DPositive { + self.radius_minimum + } + + /// Returns the starting trust radius. + pub(crate) const fn radius_initial(&self) -> DPositive { + self.radius_initial + } + + /// Returns the largest admissible trust radius. + pub(crate) const fn radius_maximum(&self) -> DPositive { + self.radius_maximum + } + + /// Returns the radius contraction factor. + pub(crate) const fn shrink_factor(&self) -> OpenUnitFraction { + self.shrink_factor + } + + /// Returns the radius expansion factor. + pub(crate) const fn expansion_factor(&self) -> GreaterThanOne { + self.expansion_factor + } + + /// Returns the acceptance ratio threshold. + pub(crate) const fn eta_accept(&self) -> OpenUnitFraction { + self.eta_accept + } + + /// Returns the expansion ratio threshold. + pub(crate) const fn eta_expand(&self) -> OpenUnitFraction { + self.eta_expand + } + + /// Returns the objective-resolution width in ulps. + pub(crate) const fn objective_resolution_ulps(&self) -> NonZero { + self.objective_resolution_ulps + } + + /// Returns the dogleg curvature-guard width in ulps. + pub(crate) const fn curvature_guard_ulps(&self) -> NonZero { + self.curvature_guard_ulps + } + + /// Returns the inclusive outer-iteration budget. + pub(crate) const fn maximum_outer_iterations(&self) -> NonZero { + self.maximum_outer_iterations + } +} + +const impl Default for SolverConfig { + fn default() -> Self { + const DEFAULT: SolverConfig = + const { SolverConfig::new(SolverOptions { .. }).ok().unwrap() }; + + DEFAULT + } } diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/mod.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/mod.rs index 4594ed71159..06f34d32000 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/mod.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/mod.rs @@ -50,7 +50,7 @@ mod work; mod tests; pub(crate) use self::{ - config::{SolverConfig, SolverConfigError}, + config::{SolverConfig, SolverConfigError, SolverOptions}, gram::{Gram, GramView}, prepare::{PreparationError, PreparationSettings}, receipt::ReceiptDetail, diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/newton.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/newton.rs index 2087a41c8ec..7d9238733f0 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/newton.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/newton.rs @@ -400,7 +400,7 @@ pub(super) fn newton_step( let guard = gradient_norm * product_norm - * (DPositive::from_u32(config.curvature_guard_ulps) * DPositive::EPSILON); + * (DPositive::from_u32(config.curvature_guard_ulps()) * DPositive::EPSILON); let Ok(guard) = guard.finish() else { return Err(non_finite(NewtonStage::Dogleg)); }; diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/prepare.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/prepare.rs index 52ceca2ccbc..22c4cea6594 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/prepare.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/prepare.rs @@ -36,7 +36,7 @@ use super::{ }; use crate::{ dataset::CANONICAL_DIMENSIONS, - math::{AlignedDVecN, AlignedVecN, BoxedDVecN, DPositive}, + math::{AlignedDVecN, AlignedVecN, BoxedDVecN, DPositive, d_positive, nz}, salt::policy::GeometryClass, }; @@ -73,23 +73,16 @@ pub(crate) enum PreparationError { } /// Solver-relevant knobs consumed by preparation. -/// -/// Every field carries a default, so `PreparationSettings { .. }` is the deployment configuration. -/// The tolerance default admits targets whose sums carry division rounding only. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct PreparationSettings { /// L2 penalty `λ` on contrast coefficients. /// /// The penalty never reaches the intercepts. pub regularization: DPositive = DPositive::ONE, /// Unit-sum tolerance for raw targets, in ulps of one. - pub target_sum_tolerance_ulps: NonZero = const { - NonZero::new(16).expect("sixteen is nonzero") - }, + pub target_sum_tolerance_ulps: NonZero = nz!(16), /// Floor on the initial Hessian diagonal, as a fraction of the largest curvature. - pub curvature_relative_floor: DPositive = const { - DPositive::new(1.0e-12).expect("the floor is positive") - }, + pub curvature_relative_floor: DPositive = d_positive!(1.0e-12), } /// Canonicalization and scaling evidence of one successful preparation. diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/report/probe.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/report/probe.rs index d1bf93aef89..e93dcd15e62 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/report/probe.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/report/probe.rs @@ -82,7 +82,7 @@ pub(crate) struct ProbeSettings { clippy::print_stdout, clippy::use_debug, clippy::too_many_lines, - reason = "the probe's receipt dump is its whole output; terminals and outcomes format through \ + reason = "the probe's receipt dump is its whole output, terminals and outcomes format through \ their debug forms, and the dump is one linear script" )] pub(crate) async fn probe_fold( @@ -104,14 +104,13 @@ pub(crate) async fn probe_fold( DPositive::new(strength).expect("the strength override is positive and finite"); println!( "regularization: configured {:e} overridden to {:e}", - config.solver.preparation.regularization.get(), - strength.get(), + config.solver.preparation.regularization, strength, ); config.solver.preparation.regularization = strength; } else { println!( "regularization: configured {:e}", - config.solver.preparation.regularization.get(), + config.solver.preparation.regularization, ); } @@ -267,7 +266,7 @@ fn replay_to_outer( ) -> (AcceptedPoint, DPositive) { let config = &problem.config; let mut control = SolverControl { - radius: config.radius_initial, + radius: config.radius_initial(), consecutive_rejections: 0, outer_iterations_started: 0, counters, @@ -331,7 +330,7 @@ fn replay_to_outer( let actual = accepted.objective - trial_objective; let ratio = actual / predicted; - if ratio < config.eta_accept { + if ratio < config.eta_accept() { control.counters.reject_finite_candidate(); rejected(&mut control, config) .expect("the production solve continued past this rejection"); @@ -350,13 +349,13 @@ fn replay_to_outer( control.counters.accept_candidate(); control.consecutive_rejections = 0; - if inner.is_boundary() && ratio >= config.eta_expand { - // A product of positives above the ceiling, +∞ included, lands on the finite - // maximum, so the clamp re-enters the domain. Growth by a factor above one never - // falls to zero. + if inner.is_boundary() && ratio >= config.eta_expand() { + // A product of positives above the ceiling, +∞ included, clamps to the finite + // maximum, and the clamp therefore re-enters the domain. Growth by a factor above + // one never falls to zero. control.radius = DPositive::new_unchecked( - (config.expansion_factor.get() * control.radius.get()) - .min(config.radius_maximum.get()), + (config.expansion_factor().get() * control.radius.get()) + .min(config.radius_maximum().get()), ); } } diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/report/tests.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/report/tests.rs index 613df9d427c..59816edfdf7 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/report/tests.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/report/tests.rs @@ -69,7 +69,7 @@ fn curvature_scales_at_the_origin_are_uniform() { let problem = ScaledProblem { prepared, gram: GramView::full(&gram), - config: SolverConfig { .. }, + config: SolverConfig::default(), }; let origin = BoxedDVecN::::zero(); @@ -83,7 +83,7 @@ fn curvature_scales_at_the_origin_are_uniform() { assert_eq!( reading.scale.to_bits(), expected.to_bits(), - "a uniform row's curvature scale is exactly (1/3)(2/3)", + "uniform row curvature should match p * (1 - p) in f64", ); } } @@ -121,7 +121,7 @@ fn census_readings_carry_row_weights_and_the_validated_total() { let problem = ScaledProblem { prepared, gram: GramView::full(&gram), - config: SolverConfig { .. }, + config: SolverConfig::default(), }; let origin = BoxedDVecN::::zero(); diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/solve.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/solve.rs index 8332ac93ef1..6371ce9e85e 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/solve.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/solve.rs @@ -127,12 +127,7 @@ pub(crate) fn solve( counters: WorkCounters, detail: ReceiptDetail, ) -> SolverRun { - debug_assert!( - problem.config.validate().is_ok(), - "the solver configuration is validated", - ); - - let mut control = SolverControl::new(problem.config.radius_initial, counters); + let mut control = SolverControl::new(problem.config.radius_initial(), counters); let mut receipts = Vec::new(); let mut certificate = None; @@ -191,7 +186,7 @@ fn run( return certify(problem, accepted, control, threshold); } - if control.outer_iterations_started == config.maximum_outer_iterations.get() { + if control.outer_iterations_started == config.maximum_outer_iterations().get() { return Err(SolverFailure::OuterIterationBudget); } control.outer_iterations_started += 1; @@ -207,8 +202,9 @@ fn run( return Err(SolverFailure::InvalidPredictedReduction); }; - let resolution = objective_resolution(accepted.objective, config.objective_resolution_ulps) - .ok_or(SolverFailure::ResolutionScaleOverflow)?; + let resolution = + objective_resolution(accepted.objective, config.objective_resolution_ulps()) + .ok_or(SolverFailure::ResolutionScaleOverflow)?; if predicted <= resolution { return Err(SolverFailure::ResolutionStall); } @@ -235,7 +231,7 @@ fn run( return Err(SolverFailure::InvalidAcceptanceRatio); }; - if ratio < config.eta_accept { + if ratio < config.eta_accept() { if let Some(recorded) = recorded.as_deref_mut() { recorded.candidate = Some(CandidateOutcome::RejectedByRatio); } @@ -278,13 +274,13 @@ fn run( control.consecutive_rejections = 0; // Only a validated boundary step at or above the expansion ratio grows the radius. - if inner.is_boundary() && ratio >= config.eta_expand { - // A product of positives above the ceiling, +∞ included, lands on the finite - // maximum, so the clamp re-enters the domain. Growth by a factor above one never + if inner.is_boundary() && ratio >= config.eta_expand() { + // A product of positives above the ceiling, +∞ included, clamps to the finite + // maximum, and the clamp re-enters the domain. Growth by a factor above one never // falls to zero. control.radius = DPositive::new_unchecked( - (config.expansion_factor.get() * control.radius.get()) - .min(config.radius_maximum.get()), + (config.expansion_factor().get() * control.radius.get()) + .min(config.radius_maximum().get()), ); } } @@ -398,14 +394,14 @@ pub(super) const fn rejected( // The typed equality is exact: the minimum radius is reached only through an exact clip to // its bytes. - if control.radius == config.radius_minimum { + if control.radius == config.radius_minimum() { return Err(SolverFailure::RadiusUnderflow); } - control.radius = match (config.shrink_factor * control.radius).finish() { - Ok(radius) => radius.max(config.radius_minimum), + control.radius = match (config.shrink_factor() * control.radius).finish() { + Ok(radius) => radius.max(config.radius_minimum()), // a positive fraction cannot overflow the radius. A rejected product rounded to zero. - Err(_) => config.radius_minimum, + Err(_) => config.radius_minimum(), }; Ok(()) } @@ -470,30 +466,31 @@ fn curvature_diagnostic( #[cfg(test)] mod tests { - use super::{SolverConfig, SolverControl, SolverFailure, WorkCounters, rejected}; + use super::{ + super::config::SolverOptions, SolverConfig, SolverControl, SolverFailure, WorkCounters, + rejected, + }; use crate::math::{d_positive, open_unit_fraction}; #[test] fn rejected_product_underflow() { - let config = SolverConfig { + let config = SolverConfig::new(SolverOptions { radius_minimum: d_positive!(1e-300), radius_initial: d_positive!(1e-200), shrink_factor: open_unit_fraction!(1e-200), .. - }; - config - .validate() - .expect("the radii and thresholds are ordered"); - let mut control = SolverControl::new(config.radius_initial, WorkCounters::default()); + }) + .expect("should satisfy the radius and threshold constraints"); + let mut control = SolverControl::new(config.radius_initial(), WorkCounters::default()); assert_eq!(rejected(&mut control, &config), Ok(())); - assert_eq!(control.radius, config.radius_minimum); + assert_eq!(control.radius, config.radius_minimum()); assert_eq!(control.consecutive_rejections, 1); core::assert_matches!( rejected(&mut control, &config), Err(SolverFailure::RadiusUnderflow) ); - assert_eq!(control.radius, config.radius_minimum); + assert_eq!(control.radius, config.radius_minimum()); assert_eq!(control.consecutive_rejections, 2); } } diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/tests.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/tests.rs index fcebfb75759..b314fea5b92 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/tests.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/solver/tests.rs @@ -3,10 +3,7 @@ reason = "bit-exact assertions are contracts on exactly representable values" )] -use core::{ - assert_matches, - num::{NonZeroU32, NonZeroU64}, -}; +use core::{assert_matches, num::NonZeroU32}; use super::{ super::{ @@ -16,7 +13,7 @@ use super::{ AUGMENTED_DIMENSIONS, CONTRAST_ROWS, ContrastVector, SOLVER_DIMENSIONS, basis::{self, HELMERT_V1}, boundary::{GROSS_DEFECT_GUARD, boundary_step}, - config::{SolverConfig, SolverConfigError}, + config::{SolverConfig, SolverConfigError, SolverOptions}, flat as flat_vectors, gram::{Gram, GramView}, newton::{NewtonOutcome, NewtonTag, factor_block, newton_step}, @@ -37,14 +34,14 @@ use crate::{ integrity::{Sha256, Sha256Digest, Update as _}, math::{ AlignedVecN, BoxedDVecN, BoxedVecN, DNonNegative, DPositive, DVecN, OpenUnitFraction, - d_finite, d_non_negative, d_positive, greater_than_one, open_unit_fraction, + d_finite, d_non_negative, d_positive, greater_than_one, nz, open_unit_fraction, }, salt::policy::GeometryClass, }; /// One ulp of unit-sum tolerance. fn one_ulp() -> NonZeroU32 { - NonZeroU32::new(1).expect("one is nonzero") + nz!(1) } /// A flat solver vector with the given components set. @@ -365,7 +362,7 @@ fn closed_target_tolerance_boundary_is_inclusive() { ); // A wider tolerance accepts the same triple. - let two_ulps = NonZeroU32::new(2).expect("two is nonzero"); + let two_ulps = nz!(2); ClosedTarget::new(beyond, two_ulps).expect("two ulps cover the deviation"); } @@ -663,7 +660,7 @@ fn closed_target_records_the_derived_adjustment() { assert_eq!( evidence.adjustment, (u_2 - raw[2] / evidence.sum).abs(), - "the stored components are the quotients themselves, so only the derived component adjusts", + "the stored components are the quotients themselves: only the derived component adjusts", ); } @@ -995,7 +992,7 @@ fn non_finite_requests_start_no_traversal() { /// Every special spacing case of the objective resolution holds exactly. #[test] fn objective_resolution_pins_the_ulp_exceptional_cases() { - let four = NonZeroU32::new(4).expect("four is nonzero"); + let four = nz!(4); // Zero and subnormal magnitudes resolve at the smallest positive subnormal spacing. assert_eq!( @@ -1047,9 +1044,8 @@ fn objective_resolution_pins_the_ulp_exceptional_cases() { assert_eq!(objective_resolution(f64::NEG_INFINITY, one_ulp()), None); } -/// A fully in-domain solver configuration. -fn solver_config() -> SolverConfig { - SolverConfig { +fn solver_options() -> SolverOptions { + SolverOptions { preparation: settings(), radius_minimum: d_positive!(1.0e-8), radius_initial: DPositive::ONE, @@ -1060,48 +1056,51 @@ fn solver_config() -> SolverConfig { eta_expand: open_unit_fraction!(0.75), relative_scaled_gradient_tolerance: open_unit_fraction!(1.0e-6), absolute_scaled_gradient_tolerance: d_non_negative!(1.0e-10), - objective_resolution_ulps: NonZeroU32::new(4).expect("four is nonzero"), - curvature_guard_ulps: NonZeroU32::new(16).expect("sixteen is nonzero"), - maximum_outer_iterations: NonZeroU64::new(100).expect("one hundred is nonzero"), + objective_resolution_ulps: nz!(4), + curvature_guard_ulps: nz!(16), + maximum_outer_iterations: nz!(100), } } +/// Admits a fixture's raw solver options. +fn validated_solver(options: SolverOptions) -> SolverConfig { + SolverConfig::new(options).expect("the solver config is valid") +} + +/// A fully in-domain solver configuration. +fn solver_config() -> SolverConfig { + validated_solver(solver_options()) +} + /// The in-domain fixture and the cross-field boundaries validate. /// /// Per-field domains hold by construction. Only the cross-field orderings remain for the /// constructor to accept. #[test] fn config_accepts_the_domain_boundaries() { - solver_config() - .validate() - .expect("the fixture is in-domain"); - // Equalities inside the radius chain are inclusive. - SolverConfig { + validated_solver(SolverOptions { radius_minimum: DPositive::ONE, radius_initial: DPositive::ONE, radius_maximum: DPositive::ONE, - ..solver_config() - } - .validate() - .expect("a degenerate radius chain is in-domain"); + ..solver_options() + }); } /// Validation rejects every violated cross-field ordering by name. #[test] fn config_rejects_misordered_fields() { - let base = solver_config(); + let base = solver_options(); // Every radius is individually in-domain, and only the ordering fails. for (minimum, initial, maximum) in [(2.0, 1.0, 3.0), (1.0, 3.0, 2.0), (2.0, 2.0, 1.0)] { assert_matches!( - SolverConfig { + SolverConfig::try_from(SolverOptions { radius_minimum: DPositive::new(minimum).expect("the case radius is positive"), radius_initial: DPositive::new(initial).expect("the case radius is positive"), radius_maximum: DPositive::new(maximum).expect("the case radius is positive"), ..base - } - .validate(), + }), Err(SolverConfigError::RadiusDomain { .. }), ); } @@ -1109,12 +1108,11 @@ fn config_rejects_misordered_fields() { // Both thresholds are individually interior, leaving equality and inversion as the violations. for (accept, expand) in [(0.5, 0.5), (0.5, 0.1)] { assert_matches!( - SolverConfig { + SolverConfig::try_from(SolverOptions { eta_accept: OpenUnitFraction::new(accept).expect("the case threshold is interior"), eta_expand: OpenUnitFraction::new(expand).expect("the case threshold is interior"), ..base - } - .validate(), + }), Err(SolverConfigError::AcceptanceThresholds { .. }), ); } @@ -1126,11 +1124,13 @@ fn config_rejects_misordered_fields() { /// away. #[test] fn gradient_threshold_follows_the_ruled_domain() { - let config = SolverConfig { + let config: SolverConfig = SolverOptions { absolute_scaled_gradient_tolerance: DNonNegative::ZERO, relative_scaled_gradient_tolerance: open_unit_fraction!(1.0e-6), - ..solver_config() - }; + ..solver_options() + } + .try_into() + .expect("the solver config is valid"); // With the absolute floor at zero the threshold is the pure relative term. assert_eq!( @@ -1144,10 +1144,11 @@ fn gradient_threshold_follows_the_ruled_domain() { assert_eq!(zero_threshold, 0.0); // A positive absolute floor dominates small initial norms. - let floored = SolverConfig { + let floored = validated_solver(SolverOptions { absolute_scaled_gradient_tolerance: d_non_negative!(1.0e-4), - ..config - }; + relative_scaled_gradient_tolerance: open_unit_fraction!(1.0e-6), + ..solver_options() + }); assert_eq!( floored.gradient_threshold(d_non_negative!(1.0)), d_non_negative!(1.0e-4) @@ -1231,7 +1232,7 @@ fn boundary_step_survives_an_irrational_crossing() { &hessian_direction, d_positive!(3.0), ) - .expect("the crossing passes both norm gates"); + .expect("the crossing passes both norm checks"); // τ = 1.875/√0.9375: the untouched coordinate stays exact, the advanced ones carry only // rounding-level error against the closed forms 1.5·τ and 0.5·τ. @@ -1686,7 +1687,7 @@ fn solve_converges_on_the_fixture_corpus() { ); let first = &run.receipts[0]; assert_eq!(first.outer_iteration, 1); - assert_eq!(first.radius, config.radius_initial.get()); + assert_eq!(first.radius, config.radius_initial().get()); assert_eq!( first.digests.zeta, vector_digest(&BoxedDVecN::::zero()) @@ -1751,7 +1752,7 @@ fn solve_converges_on_the_fixture_corpus() { Some(CurvatureDiagnostic::Value { along, normalized }) if along > 0.0 && normalized > 0.0, ); - assert_matches!(receipt.outcome.ratio, Some(ratio) if ratio >= config.eta_accept.get()); + assert_matches!(receipt.outcome.ratio, Some(ratio) if ratio >= config.eta_accept().get()); curvatures += 1; } assert_eq!(curvatures, counters.candidate_acceptances); @@ -1769,11 +1770,11 @@ fn solve_certificate_tie_returns_at_equality() { let tie = run_solver( &corpus, - SolverConfig { + validated_solver(SolverOptions { absolute_scaled_gradient_tolerance: initial_norm, relative_scaled_gradient_tolerance: open_unit_fraction!(1.0e-12), - ..solver_config() - }, + ..solver_options() + }), ); tie.outcome.expect("equality with the threshold certifies"); @@ -1790,12 +1791,12 @@ fn solve_fails_the_outer_iteration_budget() { let corpus = valid_corpus(); let run = run_solver( &corpus, - SolverConfig { - maximum_outer_iterations: NonZeroU64::new(1).expect("one is nonzero"), + validated_solver(SolverOptions { + maximum_outer_iterations: nz!(1), absolute_scaled_gradient_tolerance: DNonNegative::ZERO, relative_scaled_gradient_tolerance: open_unit_fraction!(1.0e-12), - ..solver_config() - }, + ..solver_options() + }), ); assert_matches!(run.outcome, Err(SolverFailure::OuterIterationBudget)); @@ -1806,7 +1807,7 @@ fn solve_fails_the_outer_iteration_budget() { #[test] fn solve_underflows_the_radius_on_a_rejection_at_the_minimum() { let corpus = valid_corpus(); - let strict = SolverConfig { + let strict = validated_solver(SolverOptions { radius_minimum: d_positive!(2.0), radius_initial: d_positive!(2.0), radius_maximum: d_positive!(1.0e4), @@ -1814,8 +1815,8 @@ fn solve_underflows_the_radius_on_a_rejection_at_the_minimum() { eta_expand: open_unit_fraction!(0.999), absolute_scaled_gradient_tolerance: DNonNegative::ZERO, relative_scaled_gradient_tolerance: open_unit_fraction!(1.0e-12), - ..solver_config() - }; + ..solver_options() + }); // the strict acceptance threshold 0.995 rejects this candidate. With the initial radius // already at the minimum, rejection returns `RadiusUnderflow`. @@ -1842,12 +1843,12 @@ fn solve_stalls_at_the_objective_resolution() { let corpus = valid_corpus(); let run = run_solver( &corpus, - SolverConfig { + validated_solver(SolverOptions { objective_resolution_ulps: NonZeroU32::new(u32::MAX).expect("nonzero"), absolute_scaled_gradient_tolerance: DNonNegative::ZERO, relative_scaled_gradient_tolerance: open_unit_fraction!(1.0e-12), - ..solver_config() - }, + ..solver_options() + }), ); assert_matches!(run.outcome, Err(SolverFailure::ResolutionStall)); @@ -1859,13 +1860,13 @@ fn solve_expands_the_radius_on_an_expanded_boundary_step() { let corpus = valid_corpus(); let run = run_solver( &corpus, - SolverConfig { + validated_solver(SolverOptions { radius_initial: d_positive!(1.0e-3), - maximum_outer_iterations: NonZeroU64::new(1).expect("one is nonzero"), + maximum_outer_iterations: nz!(1), absolute_scaled_gradient_tolerance: DNonNegative::ZERO, relative_scaled_gradient_tolerance: open_unit_fraction!(1.0e-12), - ..solver_config() - }, + ..solver_options() + }), ); // The `1e-3` radius forces a boundary step whose small-step ratio expands the radius once, the @@ -1898,15 +1899,15 @@ fn solve_reaches_the_non_finite_newton_terminal_on_a_degenerate_scale() { let run = run_solver( &corpus, - SolverConfig { + validated_solver(SolverOptions { preparation: PreparationSettings { regularization: DPositive::new(subnormal).expect("the subnormal is positive"), curvature_relative_floor: d_positive!(1.0e-310), ..settings() }, absolute_scaled_gradient_tolerance: DNonNegative::ZERO, - ..solver_config() - }, + ..solver_options() + }), ); assert_matches!( @@ -1945,15 +1946,15 @@ fn solve_fails_final_certification_on_a_non_finite_admitted_objective() { let run = run_solver( &corpus, - SolverConfig { + validated_solver(SolverOptions { preparation: PreparationSettings { regularization: DPositive::ONE, curvature_relative_floor: DPositive::ONE, ..settings() }, absolute_scaled_gradient_tolerance: d_non_negative!(4.0), - ..solver_config() - }, + ..solver_options() + }), ); assert_matches!(run.outcome, Err(SolverFailure::FinalCertificationNonFinite)); @@ -1975,7 +1976,7 @@ fn solve_fails_final_certification_on_a_non_finite_admitted_objective() { #[test] #[expect( clippy::host_endian_bytes, - reason = "the digest preimage is native in-memory bytes; the component walk proves the \ + reason = "the digest preimage is native in-memory bytes, and the component walk proves the \ preimage layout on every environment" )] fn receipt_domain_tag_and_dimension_are_the_exact_digest_prefix() { @@ -2108,11 +2109,11 @@ fn certify_reproves_the_certificate_freshly() { /// Rejection clips the shrink to the minimum, permits one attempt there, then underflows. #[test] fn rejected_clips_to_the_minimum_then_underflows_with_one_clipped_attempt() { - let config = SolverConfig { + let config = validated_solver(SolverOptions { radius_minimum: d_positive!(0.5), shrink_factor: open_unit_fraction!(0.25), - ..solver_config() - }; + ..solver_options() + }); let mut control = SolverControl { radius: d_positive!(1.0), consecutive_rejections: 0, @@ -2248,9 +2249,6 @@ fn newton_at_origin( WorkCounters, WorkCounters, ) { - config - .validate() - .expect("the witness configuration is valid"); let mut counters = WorkCounters::default(); let prepared = prepare( corpus.embeddings(), @@ -2260,7 +2258,7 @@ fn newton_at_origin( ) .expect("the witness corpus prepares"); let gram = Gram::assemble(corpus.embeddings(), &mut counters); - let radius = config.radius_initial; + let radius = config.radius_initial(); let problem = ScaledProblem { prepared, gram: GramView::full(&gram), @@ -2292,17 +2290,19 @@ fn newton_at_origin( #[test] fn newton_step_inverts_the_oracle_within_its_residual() { let corpus = valid_corpus(); - let config = SolverConfig { + let config = validated_solver(SolverOptions { radius_initial: d_positive!(1.0e4), - ..solver_config() - }; + ..solver_options() + }); let (outcome, baseline, counters) = newton_at_origin(&corpus, config); let outcome = outcome.expect("the wide radius keeps the Newton point interior"); assert_eq!(outcome.tag(), NewtonTag::NewtonInterior); assert!(!outcome.is_boundary()); - let residual = outcome.residual().expect("the interior point is priced"); + let residual = outcome + .residual() + .expect("the interior point has a residual estimate"); assert!( residual <= 1.0e-12, "the near-identity fixture keeps the oracle residual at rounding scale, got {residual:e}", @@ -2339,11 +2339,11 @@ fn newton_step_inverts_the_oracle_within_its_residual() { #[test] fn newton_step_crosses_the_steepest_boundary_on_a_small_radius() { let corpus = valid_corpus(); - let config = SolverConfig { + let config = validated_solver(SolverOptions { radius_initial: d_positive!(1.0e-4), - ..solver_config() - }; - let radius = config.radius_initial.get(); + ..solver_options() + }); + let radius = config.radius_initial().get(); let (outcome, baseline, counters) = newton_at_origin(&corpus, config); let outcome = outcome.expect("the small-radius solve exits through the boundary"); @@ -2376,10 +2376,10 @@ fn newton_step_crosses_the_steepest_boundary_on_a_small_radius() { #[test] fn newton_step_crosses_the_dogleg_leg_between_cauchy_and_newton() { let corpus = valid_corpus(); - let wide = SolverConfig { + let wide = validated_solver(SolverOptions { radius_initial: d_positive!(1.0e4), - ..solver_config() - }; + ..solver_options() + }); let (outcome, _, _) = newton_at_origin(&corpus, wide); let newton_norm = outcome @@ -2431,11 +2431,11 @@ fn newton_step_crosses_the_dogleg_leg_between_cauchy_and_newton() { ); let radius = f64::midpoint(cauchy_norm, newton_norm.get()); - let between = SolverConfig { + let between = validated_solver(SolverOptions { radius_initial: DPositive::new(radius).expect("the measured radius is positive"), radius_maximum: d_positive!(1.0e4), - ..solver_config() - }; + ..solver_options() + }); let (outcome, baseline, counters) = newton_at_origin(&corpus, between); let outcome = outcome.expect("the between radius exits through the dogleg leg"); @@ -2464,14 +2464,11 @@ fn newton_step_crosses_the_dogleg_leg_between_cauchy_and_newton() { #[test] fn newton_step_survives_saturated_rows() { let corpus = valid_corpus(); - let config = SolverConfig { + let config = validated_solver(SolverOptions { radius_initial: d_positive!(1.0e8), radius_maximum: d_positive!(1.0e8), - ..solver_config() - }; - config - .validate() - .expect("the witness configuration is valid"); + ..solver_options() + }); let mut counters = WorkCounters::default(); let prepared = prepare( @@ -2497,7 +2494,7 @@ fn newton_step_survives_saturated_rows() { .expect("the saturated point evaluates finitely"); let mut control = SolverControl { - radius: config.radius_initial, + radius: config.radius_initial(), consecutive_rejections: 0, outer_iterations_started: 0, counters, diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/tests.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/tests.rs index 1db77c82d96..2d6414699c9 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/fit/tests.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/fit/tests.rs @@ -3,19 +3,26 @@ reason = "bit-exact assertions are contracts on exactly representable values" )] -use core::{assert_matches, num::NonZeroU64}; -use std::sync::Mutex; +use alloc::sync::Arc; +use core::{assert_matches, mem, time::Duration}; +use std::sync::{Condvar, Mutex}; use hashql_core::id::{Id as _, IdSlice, IdVec}; +use tracing::{Dispatch, Event, Span, Subscriber, span::Id}; +use tracing_subscriber::{ + Layer, Registry, + layer::{Context, SubscriberExt as _}, + registry::LookupSpan, +}; use super::{ - FitConfig, FitError, FoldedTraining, TrainingRow, TrainingSet, TrainingSetError, applicability, - calibration, fit, grouped_folds, + FitConfig, FitError, FitOptions, FoldedTraining, TrainingRow, TrainingSet, TrainingSetError, + applicability, calibration, fit, grouped_folds, objective::{PARAMETER_COUNT, Parameters}, regularization, solver::{ Gram, PreparationError, PreparationSettings, SolverConfig, SolverConfigError, - SolverFailure, WorkCounters, + SolverFailure, SolverOptions, WorkCounters, }, split_parameters, }; @@ -23,7 +30,7 @@ use crate::{ dataset::CANONICAL_DIMENSIONS, identity::CardRow, integrity::{Sha256, Sha256Digest, Update as _}, - math::{AlignedVecN, BoxedVecN, DNonNegative, DPositive, d_positive}, + math::{AlignedVecN, BoxedVecN, DNonNegative, DPositive, d_positive, nz}, progress::{NoProgress, Progress}, salt::policy::GeometryClass, }; @@ -93,9 +100,17 @@ fn digest(bytes: &[u8]) -> Sha256Digest { hasher.finalize() } +/// Admits a fixture's raw solver options. +fn solver_config(options: SolverOptions) -> SolverConfig { + SolverConfig::new(options).expect("the solver config is valid") +} + +/// Builds a two-fold fit config at seed 17. +/// +/// Regularisation `0.5`, default solver settings otherwise. fn config() -> FitConfig { - FitConfig { - solver: SolverConfig { + FitConfig::new(FitOptions { + solver: SolverOptions { preparation: PreparationSettings { regularization: d_positive!(0.5), .. @@ -104,7 +119,8 @@ fn config() -> FitConfig { }, folds: 2, seed: 17, - } + }) + .expect("the fit config is valid") } /// Builds a two-row corpus with mixed targets and distinct groups. @@ -278,10 +294,10 @@ fn stronger_regularization_shrinks_the_fitted_coefficients() { let training = corpus.training(); let regularized = |regularization| FitConfig { - solver: SolverConfig { + solver: solver_config(SolverOptions { preparation: PreparationSettings { regularization, .. }, .. - }, + }), ..config() }; @@ -645,10 +661,10 @@ fn exhausted_outer_iteration_budget_is_an_error() { let error = fit( corpus.training(), FitConfig { - solver: SolverConfig { - maximum_outer_iterations: NonZeroU64::new(1).expect("one is nonzero"), + solver: solver_config(SolverOptions { + maximum_outer_iterations: nz!(1), .. - }, + }), ..config() }, &NoProgress, @@ -658,33 +674,141 @@ fn exhausted_outer_iteration_budget_is_an_error() { assert_matches!(error, FitError::Solver(SolverFailure::OuterIterationBudget)); } +/// One solver diagnostic as the layer saw it. +/// +/// The rayon worker that emitted it and the span ids from the root down. +struct SolveEvent { + worker: usize, + scope: Vec, +} + +/// A tracing layer that waits for two fit diagnostics and records their worker and span ids. +struct SolveScopes { + events: Arc>>, + another_worker: Condvar, +} + +impl LookupSpan<'lookup>> Layer for SolveScopes { + /// Records a fit-target event's worker and spans, waiting for a second matching event. + /// + /// # Panics + /// + /// Panics when a matching event is not on a rayon pool worker, fewer than two matching events + /// have been recorded after ten seconds, or the fixture mutex is poisoned. + fn on_event(&self, event: &Event<'_>, context: Context<'_, S>) { + if event.metadata().target() != "hash_graph_atlas::salt::policy::classifier::fit" { + return; + } + + let scope = context.event_scope(event).map_or_else(Vec::new, |scope| { + scope.from_root().map(|span| span.id()).collect() + }); + let both_workers = { + let mut events = self.events.lock().expect("should lock the solve events"); + events.push(SolveEvent { + worker: rayon::current_thread_index().expect("should execute on a pool worker"), + scope, + }); + self.another_worker.notify_all(); + + // hold the first diagnostic until another worker emits one, before either solve returns + // its error. The timeout bounds a failed rendezvous. + self.another_worker + .wait_timeout_while(events, Duration::from_secs(10), |events| events.len() < 2) + .map(|(events, _)| events.len() >= 2) + .expect("should await another worker's diagnostic") + }; + assert!(both_workers, "should observe both workers before returning"); + } +} + +/// Nested fold solves retain the active span and release it after a solver error. #[test] -fn configuration_violations_are_named() { - let corpus = mixed_corpus(); +fn select_tracing_context() { + let corpus = soft_corpus(); + let config = FitConfig { + solver: solver_config(SolverOptions { + maximum_outer_iterations: nz!(1), + .. + }), + ..config() + }; + let folds = grouped_folds(&corpus.rows, config.folds, config.seed) + .expect("should assign the corpus groups"); + let gram = Gram::assemble(corpus.embeddings().as_raw(), &mut WorkCounters::default()); + let folded = FoldedTraining { + training: corpus.training(), + folds: &folds, + gram: &gram, + }; + let events = Arc::new(Mutex::new(Vec::new())); + let dispatch = Dispatch::new(Registry::default().with(SolveScopes { + events: Arc::clone(&events), + another_worker: Condvar::new(), + })); + + tracing::dispatcher::with_default(&dispatch, || { + rayon::ThreadPoolBuilder::new() + .num_threads(2) + .build_scoped( + |thread| tracing::dispatcher::with_default(&dispatch, || thread.run()), + |pool| { + let requests = [ + tracing::info_span!("request"), + tracing::info_span!("request"), + Span::none(), + ]; + for request in requests { + let classifier = if request.is_none() { + Span::none() + } else { + request.in_scope(|| tracing::info_span!("classifier-fit")) + }; + let expected: Vec<_> = [request.id(), classifier.id()] + .into_iter() + .flatten() + .collect(); + let result = pool + .install(|| classifier.in_scope(|| folded.select(config, &NoProgress))); + assert_eq!( + result.err().expect("should exhaust the iteration budget"), + FitError::Solver(SolverFailure::OuterIterationBudget) + ); + + let observed = mem::take( + &mut *events.lock().expect("should lock the recorded diagnostics"), + ); + let mut workers: Vec<_> = + observed.iter().map(|event| event.worker).collect(); + workers.sort_unstable(); + workers.dedup(); + assert_eq!(workers, [0, 1]); + for event in observed { + assert_eq!(event.scope, expected); + } + assert_eq!(pool.broadcast(|_| Span::current().id()), [None, None]); + } + }, + ) + .expect("should build and join the subscribed pool"); + }); +} - let error = fit( - corpus.training(), - FitConfig { - folds: 1, - ..config() - }, - &NoProgress, - ) - .expect_err("one fold cannot hold anything out"); +/// The constructor names a single fold and an inverted radius range. +#[test] +fn configuration_violations_are_named() { + let error = + FitConfig::new(FitOptions { folds: 1, .. }).expect_err("one fold cannot hold anything out"); assert_matches!(error, FitError::FoldCount { folds: 1 }); - let error = fit( - corpus.training(), - FitConfig { - solver: SolverConfig { - radius_minimum: d_positive!(2.0), - radius_maximum: d_positive!(1.0), - .. - }, - ..config() + let error = FitConfig::new(FitOptions { + solver: SolverOptions { + radius_minimum: d_positive!(2.0), + radius_maximum: d_positive!(1.0), + .. }, - &NoProgress, - ) + .. + }) .expect_err("the radius ordering is violated"); assert_matches!( error, @@ -778,10 +902,10 @@ fn a_fit_that_never_converges_completes_no_fold() { fit( corpus.training(), FitConfig { - solver: SolverConfig { - maximum_outer_iterations: NonZeroU64::new(1).expect("one is nonzero"), + solver: solver_config(SolverOptions { + maximum_outer_iterations: nz!(1), .. - }, + }), ..config() }, &progress, diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/mod.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/mod.rs index 6a9be6c5868..b8a5759f819 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/mod.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/mod.rs @@ -58,9 +58,9 @@ mod tests; reason = "the generation runner and precedence resolution consume the fit surface" )] pub(crate) use self::fit::{ - Fit, FitConfig, FitError, FitEvidence, NewtonStage, PreparationError, PreparationSettings, - SolverConfig, SolverConfigError, SolverFailure, TrainingRow, TrainingSet, TrainingSetError, - fit, + Fit, FitConfig, FitError, FitEvidence, FitOptions, NewtonStage, PreparationError, + PreparationSettings, SolverConfig, SolverConfigError, SolverFailure, SolverOptions, + TrainingRow, TrainingSet, TrainingSetError, fit, }; const _: () = assert!(CANONICAL_DIMENSIONS.is_multiple_of(8)); diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/report/mod.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/report/mod.rs index 4c28070ce10..763df173393 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/report/mod.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/report/mod.rs @@ -190,8 +190,8 @@ impl ClassifierReport { coefficient_norms, intercepts: refit.classifier.intercepts, temperature, - folds: config.folds, - seed: config.seed, + folds: config.folds(), + seed: config.seed(), iterations: refit.evidence.iterations, regularization: refit.evidence.regularization, selection: refit.evidence.selection, diff --git a/libs/@local/graph/atlas/src/salt/policy/classifier/report/replay.rs b/libs/@local/graph/atlas/src/salt/policy/classifier/report/replay.rs index 75868a4b293..a60931ef76b 100644 --- a/libs/@local/graph/atlas/src/salt/policy/classifier/report/replay.rs +++ b/libs/@local/graph/atlas/src/salt/policy/classifier/report/replay.rs @@ -24,6 +24,7 @@ use super::super::fit::{FitConfig, TrainingRow}; use crate::{ dataset::CANONICAL_DIMENSIONS, file::{ + ArtifactFile as _, array::ArrayFile, generation::{GenerationId, GenerationRoot}, repository::Artifact as _, @@ -256,7 +257,7 @@ impl Frozen { staged_hashes_digest: hashes_digest, staged_classifier_digest: None, assembly: AssemblyConfig { .. }, - fit: FitConfig { .. }, + fit: FitConfig::default(), } } diff --git a/libs/@local/graph/atlas/src/salt/policy/mod.rs b/libs/@local/graph/atlas/src/salt/policy/mod.rs index 4e1d52b91cb..d1587e54fd3 100644 --- a/libs/@local/graph/atlas/src/salt/policy/mod.rs +++ b/libs/@local/graph/atlas/src/salt/policy/mod.rs @@ -11,7 +11,7 @@ //! attraction stays bounded while a mistaken repulsion destroys local structure. #![expect(clippy::empty_enums, reason = "zerocopy derive")] -use core::{fmt, mem, ops}; +use core::{fmt, marker::PhantomData, mem, ops}; use crate::{ identity::OntologyRowId, @@ -26,8 +26,10 @@ mod precedence; #[cfg(test)] mod tests; +#[cfg(test)] +pub(crate) use self::precedence::PolicySource; pub(crate) use self::precedence::{ - Classification, CoincidentAdmission, PolicyOverride, PolicySource, ResolveError, resolve, + Classification, CoincidentAdmission, PolicyOverride, ResolveError, resolve, }; /// Geometry classes a relation type distributes over. @@ -103,13 +105,42 @@ impl fmt::Display for GeometryClass { } } +/// A decoded posterior does not sum to one. +#[derive(Debug)] +struct UnvalidatedPosteriorError { + _marker: PhantomData<()>, +} + +impl fmt::Display for UnvalidatedPosteriorError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("posterior must sum to one") + } +} + +/// Raw posterior components admitted by [`Posterior::new`]. +#[derive(Debug, serde::Deserialize)] +struct UnvalidatedPosterior([UnitFraction; GeometryClass::COUNT]); + +impl TryFrom for Posterior { + type Error = UnvalidatedPosteriorError; + + fn try_from( + UnvalidatedPosterior(components): UnvalidatedPosterior, + ) -> Result { + Self::new(components).ok_or(UnvalidatedPosteriorError { + _marker: PhantomData, + }) + } +} + /// A distribution over the geometry classes. /// /// Components are [`UnitFraction`]s stored in class order and sum to one within floating-point /// rounding. Construction sites are the softmax (which satisfies the invariant by construction) and /// validated artifact reads. // The sum invariant excludes byte-level constructors: no zerocopy derives. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "UnvalidatedPosterior")] pub(crate) struct Posterior([UnitFraction; GeometryClass::COUNT]); impl Posterior { @@ -125,18 +156,17 @@ impl Posterior { /// and zero is a legal component. Rejecting negative zero would make admission depend on the /// sign bit of a value arithmetic treats as zero. #[must_use] - pub(crate) fn new(components: [f64; GeometryClass::COUNT]) -> Option { - let mut validated = [UnitFraction::ZERO; GeometryClass::COUNT]; - for (slot, value) in validated.iter_mut().zip(components) { - *slot = UnitFraction::new(value)?; - } + pub(crate) fn new(components: [UnitFraction; GeometryClass::COUNT]) -> Option { + let sum = components + .iter() + .map(|component| component.get()) + .sum::(); - let sum = components.iter().sum::(); if (sum - 1.0).abs() > Self::SUM_TOLERANCE { return None; } - Some(Self(validated)) + Some(Self(components)) } /// Computes the temperature-scaled softmax of class logits. diff --git a/libs/@local/graph/atlas/src/salt/policy/precedence/mod.rs b/libs/@local/graph/atlas/src/salt/policy/precedence/mod.rs index 470ac35e5d1..0532536db59 100644 --- a/libs/@local/graph/atlas/src/salt/policy/precedence/mod.rs +++ b/libs/@local/graph/atlas/src/salt/policy/precedence/mod.rs @@ -97,7 +97,9 @@ pub(crate) enum Classification { /// A higher-precedence policy record, declared in descending precedence. /// /// The lowest variant present wins. -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[derive( + Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] pub(crate) enum PolicySource { /// An explicit human override. Human, @@ -118,7 +120,7 @@ impl fmt::Display for PolicySource { } /// One supplied policy record above the classifier in precedence. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct PolicyOverride { /// The relation type the record covers. pub relation: OntologyRowId, @@ -134,7 +136,7 @@ pub(crate) struct PolicyOverride { /// distribution passes through the mix unchanged and the Coincident force coefficient governs /// downstream. The default thresholds are maximally conservative placeholders: a generation /// enforcing admission configures them from its precision release evidence. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct CoincidentAdmission { /// Whether the generation enforces admission. pub enforced: bool = false, diff --git a/libs/@local/graph/atlas/src/salt/policy/precedence/tests.rs b/libs/@local/graph/atlas/src/salt/policy/precedence/tests.rs index 4d283194a82..bea7877f336 100644 --- a/libs/@local/graph/atlas/src/salt/policy/precedence/tests.rs +++ b/libs/@local/graph/atlas/src/salt/policy/precedence/tests.rs @@ -16,7 +16,8 @@ use crate::{ /// /// The calibrated distribution and the applicability. fn prediction(calibrated: [f64; 3], applicability: f64) -> Prediction { - let posterior = Posterior::new(calibrated).expect("test distributions are valid"); + let posterior = Posterior::new(calibrated.map(UnitFraction::new_unchecked)) + .expect("test distributions are valid"); Prediction { logits: [0.0; 3], raw: posterior, @@ -82,17 +83,32 @@ fn overrides_supersede_predictions_by_precedence() { PolicyOverride { relation: relation(1), source: PolicySource::Synthetic, - distribution: Posterior::new([0.25, 0.5, 0.25]).expect("valid"), + distribution: Posterior::new([ + unit_fraction!(0.25), + unit_fraction!(0.5), + unit_fraction!(0.25), + ]) + .expect("valid"), }, PolicyOverride { relation: relation(1), source: PolicySource::Human, - distribution: Posterior::new([0.5, 0.25, 0.25]).expect("valid"), + distribution: Posterior::new([ + unit_fraction!(0.5), + unit_fraction!(0.25), + unit_fraction!(0.25), + ]) + .expect("valid"), }, PolicyOverride { relation: relation(1), source: PolicySource::Reviewed, - distribution: Posterior::new([0.0, 0.75, 0.25]).expect("valid"), + distribution: Posterior::new([ + unit_fraction!(0.0), + unit_fraction!(0.75), + unit_fraction!(0.25), + ]) + .expect("valid"), }, ]; @@ -188,12 +204,22 @@ fn contract_violations_are_rejected() { PolicyOverride { relation: relation(1), source: PolicySource::Human, - distribution: Posterior::new([0.5, 0.25, 0.25]).expect("valid"), + distribution: Posterior::new([ + unit_fraction!(0.5), + unit_fraction!(0.25), + unit_fraction!(0.25), + ]) + .expect("valid"), }, PolicyOverride { relation: relation(1), source: PolicySource::Human, - distribution: Posterior::new([0.25, 0.5, 0.25]).expect("valid"), + distribution: Posterior::new([ + unit_fraction!(0.25), + unit_fraction!(0.5), + unit_fraction!(0.25), + ]) + .expect("valid"), }, ], CoincidentAdmission::default(), @@ -215,7 +241,12 @@ fn contract_violations_are_rejected() { &[PolicyOverride { relation: relation(2), source: PolicySource::Human, - distribution: Posterior::new([0.5, 0.25, 0.25]).expect("valid"), + distribution: Posterior::new([ + unit_fraction!(0.5), + unit_fraction!(0.25), + unit_fraction!(0.25), + ]) + .expect("valid"), }], CoincidentAdmission::default(), ) diff --git a/libs/@local/graph/atlas/src/salt/policy/tests.rs b/libs/@local/graph/atlas/src/salt/policy/tests.rs index a461436f29c..b82c6d3883a 100644 --- a/libs/@local/graph/atlas/src/salt/policy/tests.rs +++ b/libs/@local/graph/atlas/src/salt/policy/tests.rs @@ -6,6 +6,7 @@ use zerocopy::TryFromBytes as _; use super::{GeometryClass, Posterior}; +use crate::math::{UnitFraction, unit_fraction}; /// Lists the three geometry classes in discriminant order under `VARIANTS`. /// @@ -48,7 +49,12 @@ fn wire_bytes_admit_only_declared_discriminants() { /// `Posterior::new` accepts a distribution and reports each class's probability and the array. #[test] fn posterior_accepts_a_distribution() { - let posterior = Posterior::new([0.5, 0.25, 0.25]).expect("a distribution should validate"); + let posterior = Posterior::new([ + unit_fraction!(0.5), + unit_fraction!(0.25), + unit_fraction!(0.25), + ]) + .expect("a distribution should validate"); assert_eq!(posterior.probability(GeometryClass::Coincident), 0.5); assert_eq!(posterior.probability(GeometryClass::Proximal), 0.25); assert_eq!(posterior.probability(GeometryClass::Overlay), 0.25); @@ -57,22 +63,24 @@ fn posterior_accepts_a_distribution() { #[test] fn posterior_accepts_negative_zero_components() { - let posterior = - Posterior::new([-0.0, 0.5, 0.5]).expect("negative zero compares equal to a legal zero"); + let posterior = Posterior::new([ + unit_fraction!(-0.0), + unit_fraction!(0.5), + unit_fraction!(0.5), + ]) + .expect("negative zero compares equal to a legal zero"); assert_eq!(posterior.probability(GeometryClass::Coincident), 0.0); } #[test] -fn posterior_rejects_non_distributions() { - assert_eq!(Posterior::new([f64::NAN, 0.5, 0.5]), None); - assert_eq!(Posterior::new([f64::INFINITY, 0.0, 0.0]), None); - assert_eq!(Posterior::new([-0.125, 0.625, 0.5]), None); - assert_eq!(Posterior::new([0.5, 0.25, 0.125]), None); - assert_eq!(Posterior::new([0.5, 0.5, 0.125]), None); +fn posterior_tolerates_softmax_rounding() { + let rounded = [0.2, 0.3, 0.5 + 5.0e-10]; + assert!(Posterior::new(rounded.map(UnitFraction::new_unchecked)).is_some()); } #[test] -fn posterior_tolerates_softmax_rounding() { - let rounded = [0.2, 0.3, 0.5 + 5.0e-10]; - assert!(Posterior::new(rounded).is_some()); +fn posterior_deserialization_rejects_an_unnormalized_distribution() { + let error = serde_json::from_value::(serde_json::json!([0.5, 0.5, 0.5])) + .expect_err("an unnormalized posterior refuses to parse"); + assert!(error.to_string().contains("posterior must sum to one")); } diff --git a/libs/@local/graph/atlas/src/salt/postings/artifact.rs b/libs/@local/graph/atlas/src/salt/postings/artifact.rs index e766f738f7a..491b846f16c 100644 --- a/libs/@local/graph/atlas/src/salt/postings/artifact.rs +++ b/libs/@local/graph/atlas/src/salt/postings/artifact.rs @@ -2,20 +2,23 @@ //! //! The archive checks run ordering and domains before exposing membership and parent views. -use core::ops::Range; +use std::path::Path; use hashql_core::id::Id as _; use crate::{ - bitset::{DenseBitSlice, RowsIn}, - file::postings::read::PostingsFile, + bitset::DenseBitSlice, + file::{ + ArtifactFile, + postings::read::{OpenPostingsError, PostingsFile}, + }, identity::{BasePosition, OntologyRowId}, runs::{RunsError, RunsView}, }; /// A violation of the postings artifact's run or membership-count contract. #[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum InvalidPostingsFile { +pub(crate) enum InvalidPostingsFile { /// The list fenceposts break anchoring, ordering, or coverage at `position`. ListPosts { position: usize }, /// The parent fenceposts break anchoring, ordering, or coverage at `position`. @@ -99,6 +102,50 @@ impl core::fmt::Display for InvalidPostingsFile { impl core::error::Error for InvalidPostingsFile {} +/// A failure to open or validate a published postings artifact. +#[derive(Debug)] +pub(crate) enum OpenPostingsArchiveError { + /// The postings file failed to open. + Open(OpenPostingsError), + /// The file does not hold a valid postings artifact. + Invalid(InvalidPostingsFile), +} + +const impl From for OpenPostingsArchiveError { + fn from(error: OpenPostingsError) -> Self { + Self::Open(error) + } +} + +const impl From for OpenPostingsArchiveError { + fn from(error: InvalidPostingsFile) -> Self { + Self::Invalid(error) + } +} + +impl core::fmt::Display for OpenPostingsArchiveError { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Open(error) => write!(fmt, "the postings file failed to open: {error}"), + Self::Invalid(error) => { + write!( + fmt, + "the file does not hold a valid postings artifact: {error}" + ) + } + } + } +} + +impl core::error::Error for OpenPostingsArchiveError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::Open(error) => Some(error), + Self::Invalid(error) => Some(error), + } + } +} + /// A published postings artifact opened over its mapped file. /// /// Construction validates fencepost anchoring, ordering and coverage in all three run regions. It @@ -113,6 +160,15 @@ pub(crate) struct PostingsArchive { file: PostingsFile, } +impl ArtifactFile for PostingsArchive { + type Error = OpenPostingsArchiveError; + + fn open(path: impl AsRef) -> Result { + let file = PostingsFile::open(path)?; + Self::new(file).map_err(From::from) + } +} + impl PostingsArchive { /// Opens the postings over their mapped file. /// @@ -247,6 +303,7 @@ impl PostingsArchive { // against the membership total, and that is the region's whole production use. The postings // tests read it to verify the written direct map restates the input type column. #[must_use] + #[cfg(test)] pub(crate) fn direct_types(&self, position: BasePosition) -> Option<&[OntologyRowId]> { let index = position.as_u64(); if index >= self.file.points() { @@ -267,6 +324,7 @@ impl PostingsArchive { } /// Re-borrows the direct-map regions construction validated. + #[cfg(test)] // Only `direct_types` re-borrows the region after validation. fn direct_runs(&self) -> RunsView<'_, BasePosition, OntologyRowId> { RunsView::from_parts_unchecked(self.file.direct_posts(), self.file.direct_ids()) } @@ -306,48 +364,4 @@ impl Membership<'_> { Self::Dense(set) => set.contains(position), } } - - /// Iterates the member positions inside `range`, ascending. - /// - /// The shape a delivered run's mask column interleaves from. - /// - /// # Panics - /// - /// This panics when `range.start` exceeds `range.end`. Every caller supplies an ascending range - /// by construction. - pub(crate) fn positions_in(&self, range: Range) -> MembershipPositions<'_> { - match self { - Self::List(positions) => { - assert!( - range.start <= range.end, - "an inverted position range matches no delivered run", - ); - let start = positions.partition_point(|&position| position < range.start); - let end = positions.partition_point(|&position| position < range.end); - - MembershipPositions::List(positions[start..end].iter()) - } - Self::Dense(set) => MembershipPositions::Dense(set.iter_in(range)), - } - } -} - -/// Iterator over one membership's positions inside a range. -#[derive(Debug)] -pub(crate) enum MembershipPositions<'map> { - /// The member slice of a list run. - List(core::slice::Iter<'map, BasePosition>), - /// The dense set's own range cursor. - Dense(RowsIn<'map, BasePosition>), -} - -impl Iterator for MembershipPositions<'_> { - type Item = BasePosition; - - fn next(&mut self) -> Option { - match self { - Self::List(positions) => positions.next().copied(), - Self::Dense(rows) => rows.next(), - } - } } diff --git a/libs/@local/graph/atlas/src/salt/postings/build.rs b/libs/@local/graph/atlas/src/salt/postings/build.rs index 7a9d4200599..546104ad6a1 100644 --- a/libs/@local/graph/atlas/src/salt/postings/build.rs +++ b/libs/@local/graph/atlas/src/salt/postings/build.rs @@ -286,12 +286,8 @@ impl WriteInto for Postings { } } -/// The measurements of one postings build. -/// -/// What the manifest records so the representation split follows data rather than taste. Not -/// evidence: the metadata's `Evidence` section holds admission checks, while these are build -/// census numbers. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +/// Type counts and region populations of one postings build. +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct PostingsMeasurements { /// Types in the domain. pub types: u64, diff --git a/libs/@local/graph/atlas/src/salt/postings/closure.rs b/libs/@local/graph/atlas/src/salt/postings/closure.rs index d3072da4b50..432fdb64620 100644 --- a/libs/@local/graph/atlas/src/salt/postings/closure.rs +++ b/libs/@local/graph/atlas/src/salt/postings/closure.rs @@ -11,20 +11,27 @@ //! memo records row identities. Payload bytes resolve at read time against the table that owns //! them. +#[cfg(test)] +use hashql_core::id::bit_vec::RowRef; use hashql_core::id::{ Id as _, IdVec, - bit_vec::{BitMatrix, RowRef}, + bit_vec::{BitMatrix, BitRelations as _}, }; -use crate::{identity::OntologyRowId, salt::postings::artifact::PostingsArchive}; +use super::artifact::Membership; +use crate::{ + bitset::DenseBitSlice, + identity::{BasePosition, OntologyRowId}, + salt::postings::artifact::PostingsArchive, +}; /// A cycle preventing a children-first ordering of the parent graph. /// /// Type inheritance requires an acyclic parent graph. This error reports a cycle in the supplied /// parent edges. #[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct ParentCycle { - /// Types entangled in cycles: every type whose descendant set never settled. +pub(crate) struct ParentCycle { + /// Types whose descendant sets never settled: cycle members and all of their ancestors. pub entangled: u64, } @@ -60,8 +67,17 @@ pub(crate) struct IconSource { /// resolves the memoized icon ancestor. #[derive(Debug, Clone)] pub(crate) struct ClosureMap { + /// Reflexive descendant reachability for each ontology row. + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "retained descendant rows are inspected only by test helpers" + ) + )] bits: BitMatrix, icon_sources: IdVec>, + memberships: IdVec>>>, } impl ClosureMap { @@ -172,18 +188,54 @@ impl ClosureMap { icon_sources[r#type] = best; } - Ok(Self { bits, icon_sources }) + let mut memberships = IdVec::new(); + for r#type in bits.rows() { + let row = bits.row(r#type); + if row.count() == 1 { + continue; + } + + let mut membership = DenseBitSlice::new_empty( + usize::try_from(postings.points()) + .expect("resident point domains should fit usize"), + ); + for col in row { + match postings + .membership(col) + .expect("membership should be available") + { + Membership::List(base_positions) => { + for &position in base_positions { + membership.insert(position); + } + } + Membership::Dense(direct) => { + membership.union(direct); + } + } + } + + memberships.insert(r#type, membership); + } + + Ok(Self { + bits, + icon_sources, + memberships, + }) } /// Returns the type domain `T`. #[inline] #[must_use] + #[cfg(test)] // The postings tests check the derived domain against the fixture. pub(crate) const fn types(&self) -> usize { self.bits.row_domain_size() } /// Borrows `type_row`'s descendant row, when the row is in domain. #[must_use] + #[cfg(test)] // The postings tests verify the derivation against hand-derived descendant rows. pub(crate) fn descendants(&self, type_row: OntologyRowId) -> Option> { (type_row.as_usize() < self.bits.row_domain_size()).then(|| self.bits.row(type_row)) } @@ -193,8 +245,19 @@ impl ClosureMap { /// Returns [`None`] outside the type domain or for an icon-free cone. Equal-depth candidates /// resolve to the earlier parent in the artifact's ascending-row parent order. #[must_use] - pub(crate) const fn icon_source(&self, type_row: OntologyRowId) -> Option { - self.icon_sources[type_row] + pub(crate) fn icon_source(&self, type_row: OntologyRowId) -> Option { + self.icon_sources.lookup(type_row).copied() + } + + /// Borrows the base positions of every instance of `type_row` or of a type descending from it. + /// + /// Returns [`None`] outside the type domain and for a type whose only descendant is itself, + /// whose instances the postings' direct membership already names. + pub(crate) fn membership( + &self, + type_row: OntologyRowId, + ) -> Option<&DenseBitSlice> { + self.memberships.lookup(type_row).map(|slice| &**slice) } /// Returns whether `descendant` descends from `ancestor` (a type descends from itself). diff --git a/libs/@local/graph/atlas/src/salt/postings/tests.rs b/libs/@local/graph/atlas/src/salt/postings/tests.rs index e576126c449..c1f959178c1 100644 --- a/libs/@local/graph/atlas/src/salt/postings/tests.rs +++ b/libs/@local/graph/atlas/src/salt/postings/tests.rs @@ -137,10 +137,17 @@ fn count(membership: &Membership<'_>) -> u64 { /// Collects member positions inside `range` in ascending order over either encoding. fn collect(membership: &Membership<'_>, range: core::ops::Range) -> Vec { - let range = BasePosition::from_u32(range.start)..BasePosition::from_u32(range.end); - membership - .positions_in(range) - .map(BasePosition::as_u32) + let positions: Vec = match membership { + Membership::List(positions) => positions + .iter() + .copied() + .map(BasePosition::as_u32) + .collect(), + Membership::Dense(set) => set.iter().map(BasePosition::as_u32).collect(), + }; + positions + .into_iter() + .filter(|position| range.contains(position)) .collect() } @@ -245,49 +252,6 @@ fn membership_lookups_agree_across_representations() { assert_eq!(collect(&type1, 0..5), [] as [u32; 0]); } -/// The membership contract demands ascending ranges from every caller, so an inverted range -/// panics at the list representation. -#[test] -#[should_panic(expected = "an inverted position range matches no delivered run")] -fn inverted_list_ranges_are_a_caller_bug() { - let dir = scratch("inverted-list"); - let postings = Postings::build( - &fixture_types(), - IdSlice::from_raw(&ROW_OF_POSITION.map(NodeRowId::from_u32)), - &fixture_parents(), - ) - .expect("the fixture stays in domain"); - let mapped = mapped(&dir, "fixture.post", &postings); - - let type1 = mapped.membership(id(1)).expect("type 1 is in domain"); - #[expect( - clippy::reversed_empty_ranges, - reason = "the inverted range IS the case under test" - )] - let _positions = collect(&type1, 6..2); -} - -/// The dense representation delegates the same contract to the set's own cursor. -#[test] -#[should_panic(expected = "an inverted row range admits no iteration order")] -fn inverted_dense_ranges_are_a_caller_bug() { - let dir = scratch("inverted-dense"); - let postings = Postings::build( - &fixture_types(), - IdSlice::from_raw(&ROW_OF_POSITION.map(NodeRowId::from_u32)), - &fixture_parents(), - ) - .expect("the fixture stays in domain"); - let mapped = mapped(&dir, "fixture.post", &postings); - - let type0 = mapped.membership(id(0)).expect("type 0 is in domain"); - #[expect( - clippy::reversed_empty_ranges, - reason = "the inverted range IS the case under test" - )] - let _positions = collect(&type0, 6..2); -} - #[test] fn dense_iteration_crosses_word_boundaries() { // Members {0, 63, 64, 79} over eighty positions in two words. @@ -803,7 +767,7 @@ fn closure_expands_the_fixture_graph() { } #[test] -fn closure_rejects_parent_cycles() { +fn closure_parent_cycles() { let dir = scratch("cycle"); // exactly types 0 and 1 form the cycle. Type 2 has no parent or child. @@ -821,7 +785,7 @@ fn closure_rejects_parent_cycles() { /// Each type resolves its own nearest icon regardless of other types' traversal paths. #[test] -fn icon_memo_resolves_the_nearest_ancestor_icon() { +fn icon_memo_nearest_ancestor() { let dir = scratch("icon-memo"); // types 6 and 7 form a separate icon-free chain. @@ -846,6 +810,8 @@ fn icon_memo_resolves_the_nearest_ancestor_icon() { // An icon-free cone records no source, at any height. assert_eq!(closure.icon_source(id(6)), None); assert_eq!(closure.icon_source(id(7)), None); + assert_eq!(closure.icon_source(id(8)), None); + assert_eq!(closure.icon_source(id(u64::MAX)), None); } /// Depth beats run order, and run order breaks equal-depth ties. @@ -854,7 +820,7 @@ fn icon_memo_resolves_the_nearest_ancestor_icon() { /// reaches icon 0 at depth two. Type 4 reaches both icons at depth one and selects the earlier /// parent in the ascending run, row 0. #[test] -fn icon_memo_ties_resolve_by_depth_then_run_order() { +fn icon_memo_depth_and_run_order() { let dir = scratch("icon-ties"); let postings = Postings::build( &types(&[&[3, 4]]), @@ -1076,10 +1042,7 @@ fn built_postings_uphold_the_membership_contract( reference_contains(rows.as_raw(), &row_of_position, position, type_row) }) .collect(); - let full: Vec = membership - .positions_in(BasePosition::from_u32(0)..BasePosition::from_u32(points)) - .map(BasePosition::as_u32) - .collect(); + let full = collect(&membership, 0..points); prop_assert_eq!(&full, &expected, "type {}'s member positions", type_row); prop_assert_eq!(count(&membership), expected.len() as u64); diff --git a/libs/@local/graph/atlas/src/salt/projector/artifact/mod.rs b/libs/@local/graph/atlas/src/salt/projector/artifact/mod.rs index abb33c449fc..bba9bfe3692 100644 --- a/libs/@local/graph/atlas/src/salt/projector/artifact/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/artifact/mod.rs @@ -30,6 +30,7 @@ use burn::{ tensor::backend::Backend, }; +use super::train::fit::TrainingScheduleError; use crate::{ file::{WriteAs, WriteInto, salt::artifact}, integrity::{Sha256, Sha256Digest, Writer}, @@ -55,11 +56,12 @@ pub(crate) enum CheckpointError { /// The decoded parameters do not describe the architecture. Architecture(ArchitectureMismatch), /// The decoded schedule fields do not form a valid schedule. + InvalidSchedule(TrainingScheduleError), #[cfg_attr( not(test), expect(dead_code, reason = "no fit caller resumes from a checkpoint yet") )] - InvalidSchedule, + MalformedSchedule, /// The decoded scheduler position does not sit at the schedule's boundary. /// /// The record's parts describe two different runs. @@ -78,7 +80,8 @@ impl core::fmt::Display for CheckpointError { write!(fmt, "could not encode or decode the checkpoint: {error}") } Self::Architecture(error) => error.fmt(fmt), - Self::InvalidSchedule => fmt.write_str( + Self::InvalidSchedule(error) => error.fmt(fmt), + Self::MalformedSchedule => fmt.write_str( "the checkpoint's schedule fields do not form a valid training schedule", ), Self::SchedulerPosition { position, boundary } => write!( @@ -96,11 +99,18 @@ impl core::error::Error for CheckpointError { Self::Io(error) => Some(error), Self::Record(error) => Some(error), Self::Architecture(error) => Some(error), - Self::InvalidSchedule | Self::SchedulerPosition { .. } => None, + Self::InvalidSchedule(error) => Some(error), + Self::SchedulerPosition { .. } | Self::MalformedSchedule => None, } } } +impl From for CheckpointError { + fn from(value: TrainingScheduleError) -> Self { + Self::InvalidSchedule(value) + } +} + impl From for CheckpointError { #[inline] fn from(error: io::Error) -> Self { diff --git a/libs/@local/graph/atlas/src/salt/projector/artifact/tests.rs b/libs/@local/graph/atlas/src/salt/projector/artifact/tests.rs index 51edcf27560..38c9c2582b0 100644 --- a/libs/@local/graph/atlas/src/salt/projector/artifact/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/artifact/tests.rs @@ -6,6 +6,7 @@ //! full precision: a round-tripped model must compute the identical function, and any deviation //! breaks the round-trip rather than merely losing precision. +use core::assert_matches; use std::sync::LazyLock; use burn::{ @@ -147,8 +148,9 @@ fn open_model_rejects_truncated_bytes() { let error = open_model::(bytes.as_slice(), architecture(), &*DEVICE) .expect_err("truncated bytes should be rejected"); - assert!( - matches!(error, CheckpointError::Record(_)), + assert_matches!( + error, + CheckpointError::Record(_), "the rejection should name the record decode: {error}" ); } diff --git a/libs/@local/graph/atlas/src/salt/projector/band/tests.rs b/libs/@local/graph/atlas/src/salt/projector/band/tests.rs index 06338730191..653203a139d 100644 --- a/libs/@local/graph/atlas/src/salt/projector/band/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/band/tests.rs @@ -6,7 +6,7 @@ #![expect( clippy::float_cmp, - reason = "the dyadic fixtures produce exactly representable readings, so the asserted \ + reason = "the dyadic fixtures produce exactly representable readings, and the asserted \ constants are exact contracts" )] diff --git a/libs/@local/graph/atlas/src/salt/projector/bench/live.rs b/libs/@local/graph/atlas/src/salt/projector/bench/live.rs index 97445597062..38ac24d7af1 100644 --- a/libs/@local/graph/atlas/src/salt/projector/bench/live.rs +++ b/libs/@local/graph/atlas/src/salt/projector/bench/live.rs @@ -138,7 +138,7 @@ impl Fixture { .into_boxed_slice(), ); let landmarks = landmark_pool(rows, &mut rng); - let plan = crate::salt::fit::ProjectorOptions::ratified().plan; + let plan = crate::salt::fit::ProjectorOptions::live().plan; // The mined frame comes from the production miner over a synthetic // coordinate frame: pooled hard negatives at the real quota. The @@ -159,7 +159,7 @@ impl Fixture { graph.view(), indexes.protection.view(), ProtectionConfig::default(), - crate::salt::fit::ProjectorOptions::ratified().miner, + crate::salt::fit::ProjectorOptions::live().miner, ) .mine(&field); @@ -420,7 +420,7 @@ impl<'fixture> Stepper<'fixture> { /// The constants stay cost-neutral. They steer values and never operation counts. The relation /// energy is present, as in the ladder regime the ratified schedule spends most steps in. fn objective_options() -> ObjectiveOptions { - let ratified = crate::salt::fit::ProjectorOptions::ratified(); + let ratified = crate::salt::fit::ProjectorOptions::live(); ObjectiveOptions { affinity: AffinityEnergy::new( AffinityCurve::new(positive!(1.0), positive!(1.0)), @@ -572,7 +572,7 @@ fn landmark_pool(rows: usize, rng: &mut Xoshiro256PlusPlus) -> Vec Self { @@ -107,8 +107,8 @@ impl BudgetSummary { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn nodes(&self) -> usize { @@ -138,8 +138,8 @@ impl BudgetSummary { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) fn mean_ratio(&self) -> Option { diff --git a/libs/@local/graph/atlas/src/salt/projector/evidence/tests.rs b/libs/@local/graph/atlas/src/salt/projector/evidence/tests.rs index a0a05ef086f..e58c2304ef3 100644 --- a/libs/@local/graph/atlas/src/salt/projector/evidence/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/evidence/tests.rs @@ -7,7 +7,7 @@ #![expect( clippy::float_cmp, - reason = "the exact fixtures produce exactly representable readings, so the asserted \ + reason = "the exact fixtures produce exactly representable readings, and the asserted \ constants are exact contracts" )] diff --git a/libs/@local/graph/atlas/src/salt/projector/gauge/mod.rs b/libs/@local/graph/atlas/src/salt/projector/gauge/mod.rs index c7526cb19b1..48e0f5c8991 100644 --- a/libs/@local/graph/atlas/src/salt/projector/gauge/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/gauge/mod.rs @@ -139,8 +139,8 @@ where #[expect( clippy::cast_possible_truncation, - reason = "the frozen constant lives in the working f32 precision; the domain check \ - reads the narrowed value" + reason = "the frozen constant lives in the working f32 precision, and the domain \ + check reads the narrowed value" )] let frozen_spread = Positive::new(spread as f32).ok_or(GaugeRefusal::DegenerateSpread { spread })?; diff --git a/libs/@local/graph/atlas/src/salt/projector/gauge/tests.rs b/libs/@local/graph/atlas/src/salt/projector/gauge/tests.rs index be48e6b0cfc..f93e1b9ac6f 100644 --- a/libs/@local/graph/atlas/src/salt/projector/gauge/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/gauge/tests.rs @@ -7,7 +7,7 @@ #![expect( clippy::float_cmp, - reason = "the exact fixtures produce exactly representable readings, so the asserted \ + reason = "the exact fixtures produce exactly representable readings, and the asserted \ constants are exact contracts" )] @@ -390,7 +390,7 @@ fn the_residual_bar_binds_at_the_fit() { assert!((residual - 0.5 / f64::from(gauge.frozen_spread())).abs() < 1e-12); let admitted = fit_fields(&gauge, frame(&canonical), frame(&zero), Some(positive(0.5))) - .expect("the residual sits under the raised bar"); + .expect("the residual is below the increased tolerance"); assert_eq!(admitted.scale().get(), 1.0); assert_eq!(admitted.similarity.rotation().cos(), 1.0); assert_eq!(admitted.similarity.rotation().sin(), 0.0); diff --git a/libs/@local/graph/atlas/src/salt/projector/loss/contrast.rs b/libs/@local/graph/atlas/src/salt/projector/loss/contrast.rs index dc7634fc49c..255226ce16b 100644 --- a/libs/@local/graph/atlas/src/salt/projector/loss/contrast.rs +++ b/libs/@local/graph/atlas/src/salt/projector/loss/contrast.rs @@ -183,7 +183,7 @@ mod tests { /// Computes the violation in `f64` from its defining expression, for finite differences. #[expect( clippy::suboptimal_flops, - reason = "the mirror states the defining expression verbatim" + reason = "the reference states the defining expression verbatim" )] fn violation(scale: f64, margin: f64, ruler: f64, canonical: f64, zero: f64) -> f64 { (scale * canonical - zero) / ruler + margin @@ -224,7 +224,8 @@ mod tests { #[test] #[expect( clippy::cast_possible_truncation, - reason = "the fixture constants are exactly representable in f32" + reason = "the fixture constants round to f32 at the cast, and the tolerance exceeds the \ + slope error that rounding induces" )] fn partials_match_finite_differences() { let (scale, margin, ruler, canonical, zero) = diff --git a/libs/@local/graph/atlas/src/salt/projector/loss/energy.rs b/libs/@local/graph/atlas/src/salt/projector/loss/energy.rs index b9319987f01..bd3f819a003 100644 --- a/libs/@local/graph/atlas/src/salt/projector/loss/energy.rs +++ b/libs/@local/graph/atlas/src/salt/projector/loss/energy.rs @@ -1,12 +1,45 @@ //! Scalar pair energies and their hand-derived first derivatives. //! //! Every energy exposes its value together with the derivative the batch terms fold into coordinate -//! gradients, so the pair loops in the parent module stay pure plumbing. The unit tests certify -//! each derivative against a finite-difference reference. The value and derivative always compute -//! in one fused evaluation. +//! gradients. The pair loops in the parent module apply the chain rule and derive nothing +//! themselves. The unit tests certify each derivative against a finite-difference reference. The +//! value and derivative always compute in one fused evaluation. + +use core::{fmt, marker::PhantomData}; + +use serde::de::Error as _; use crate::math::{AffinityCurve, DNonNegative, Derivation, NonNegative, Positive, softplus}; +#[derive(Debug, Clone, PartialEq)] +struct UnvalidatedAffinityEnergyError { + _marker: PhantomData<()>, +} + +impl fmt::Display for UnvalidatedAffinityEnergyError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("affinity energy has a curve with a b exponent of less than 0.5") + } +} + +impl core::error::Error for UnvalidatedAffinityEnergyError {} + +#[derive(Debug, Copy, Clone, PartialEq, serde::Deserialize)] +struct UnvalidatedAffinityEnergy { + curve: AffinityCurve, + epsilon: Positive, +} + +impl TryFrom for AffinityEnergy { + type Error = UnvalidatedAffinityEnergyError; + + fn try_from(value: UnvalidatedAffinityEnergy) -> Result { + Self::new(value.curve, value.epsilon).ok_or(UnvalidatedAffinityEnergyError { + _marker: PhantomData, + }) + } +} + /// The semantic edge energy over the low-dimensional affinity. /// /// For squared pair distance `u` and affinity `q(u) = 1 / (1 + a u^b)`, attraction penalizes @@ -14,7 +47,8 @@ use crate::math::{AffinityCurve, DNonNegative, Derivation, NonNegative, Positive /// placement of a negative pair by `-ln(1 - q + ε)`. The offset keeps both logarithms finite over /// the affinity's whole range, and bounds the repulsion derivative as the pair approaches /// coincidence. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "UnvalidatedAffinityEnergy")] pub(crate) struct AffinityEnergy { curve: AffinityCurve, epsilon: Positive, @@ -98,31 +132,13 @@ impl AffinityEnergy { /// /// The energy is strictly increasing, and coincidence is its unique minimum. That residual and the /// competing terms jointly set a pair's equilibrium distance. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct ProximalEnergy { pub radius: NonNegative, pub temperature: Positive, } impl ProximalEnergy { - /// Creates a Proximal energy. - /// - /// The domains ride in the types, so there is nothing left to validate. - #[must_use] - pub(crate) const fn new(radius: NonNegative, temperature: Positive) -> Self { - Self { - radius, - temperature, - } - } - - /// Returns the target radius. - #[inline] - #[must_use] - pub(crate) const fn radius(self) -> NonNegative { - self.radius - } - /// Evaluates the energy and its derivative at a normalized distance. /// /// The derivative is the logistic function of the scaled excess: it approaches one far outside @@ -144,37 +160,15 @@ impl ProximalEnergy { /// The Coincident class energy, an outlier-resistant pull below a tight radius. /// /// `E(z) = huber(max(z - radius, 0), threshold)` is zero inside the radius, quadratic immediately -/// outside it, and linear beyond the threshold, so one far-flung pair cannot dominate a batch. The -/// derivative is continuous everywhere. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +/// outside it, and linear beyond the threshold. One far-flung pair therefore cannot dominate a +/// batch. The derivative is continuous everywhere. +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct CoincidentEnergy { pub radius: NonNegative, pub threshold: Positive, } impl CoincidentEnergy { - /// Creates a Coincident energy. - /// - /// Both settings carry their domain in the type, so construction validates nothing. - #[must_use] - pub(crate) const fn new(radius: NonNegative, threshold: Positive) -> Self { - Self { radius, threshold } - } - - /// Returns the target radius. - #[inline] - #[must_use] - pub(crate) const fn radius(self) -> NonNegative { - self.radius - } - - /// Returns the Huber threshold. - #[inline] - #[must_use] - pub(crate) const fn threshold(self) -> Positive { - self.threshold - } - /// Evaluates the energy and its derivative at a normalized distance. /// /// The derivative is zero inside the radius, the excess itself in the quadratic regime, and the @@ -198,13 +192,22 @@ impl CoincidentEnergy { /// placement than the loose one. `epsilon` guards the local scales in the normalization `z = d / /// √((scale_i + ε)(scale_j + ε))`, keeping `z` finite where a diverged neighbourhood measured a /// zero radius. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize)] pub(crate) struct RelationEnergy { coincident: CoincidentEnergy, proximal: ProximalEnergy, epsilon: Positive, } +/// Relation components awaiting validation of their radius ordering. +#[derive(serde::Deserialize)] +#[serde(rename = "RelationEnergy")] +struct UnvalidatedRelationEnergy { + coincident: CoincidentEnergy, + proximal: ProximalEnergy, + epsilon: Positive, +} + impl RelationEnergy { /// Validates a relation energy. /// @@ -215,7 +218,7 @@ impl RelationEnergy { proximal: ProximalEnergy, epsilon: Positive, ) -> Option { - (coincident.radius() < proximal.radius()).then_some(Self { + (coincident.radius < proximal.radius).then_some(Self { coincident, proximal, epsilon, @@ -265,3 +268,18 @@ impl RelationEnergy { ) } } + +impl<'de> serde::Deserialize<'de> for RelationEnergy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let components = UnvalidatedRelationEnergy::deserialize(deserializer)?; + Self::new( + components.coincident, + components.proximal, + components.epsilon, + ) + .ok_or_else(|| D::Error::custom("coincident radius must be strictly below proximal radius")) + } +} diff --git a/libs/@local/graph/atlas/src/salt/projector/loss/mod.rs b/libs/@local/graph/atlas/src/salt/projector/loss/mod.rs index 76970323b8e..f1377e1fff3 100644 --- a/libs/@local/graph/atlas/src/salt/projector/loss/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/loss/mod.rs @@ -311,41 +311,17 @@ pub(crate) struct BatchAnchor { pub row: BatchRowId, pub target: Vec2, pub radius: NonNegative, - pub weight: f32, + pub weight: Positive, } /// Validated support-term constants. /// /// `threshold` is the Huber threshold on the normalized residual. `epsilon` both guards the radius /// division and smooths the distance at coincidence. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct SupportOptions { - threshold: Positive, - epsilon: Positive, -} - -impl SupportOptions { - /// Creates support constants. - /// - /// Both values carry their domain in the type, so construction validates nothing. - #[must_use] - pub(crate) const fn new(threshold: Positive, epsilon: Positive) -> Self { - Self { threshold, epsilon } - } - - /// Returns the Huber threshold. - #[inline] - #[must_use] - pub(crate) const fn threshold(self) -> Positive { - self.threshold - } - - /// Returns the radius guard. - #[inline] - #[must_use] - pub(crate) const fn epsilon(self) -> Positive { - self.epsilon - } + pub threshold: Positive, + pub epsilon: Positive, } /// The materialized anchor set of one support term, on one device. @@ -396,7 +372,7 @@ impl SupportTargets { .collect::>(); let weights = anchors .iter() - .map(|anchor| anchor.weight) + .map(|anchor| anchor.weight.get()) .collect::>(); Some(Self { diff --git a/libs/@local/graph/atlas/src/salt/projector/loss/objective/mod.rs b/libs/@local/graph/atlas/src/salt/projector/loss/objective/mod.rs index 7bb7965ff0f..17fa39645e3 100644 --- a/libs/@local/graph/atlas/src/salt/projector/loss/objective/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/loss/objective/mod.rs @@ -76,8 +76,7 @@ pub(crate) enum UnitLaw { not(test), expect( dead_code, - reason = "the selected unit law for the planned calibration; consumed when the band \ - trainer is wired" + reason = "no production path constructs this unit law, and the tests select it" ) )] PerLinkInstance, diff --git a/libs/@local/graph/atlas/src/salt/projector/loss/objective/tests.rs b/libs/@local/graph/atlas/src/salt/projector/loss/objective/tests.rs index 6f0a5664cd9..37af3e957c7 100644 --- a/libs/@local/graph/atlas/src/salt/projector/loss/objective/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/loss/objective/tests.rs @@ -6,7 +6,7 @@ #![expect( clippy::float_cmp, - reason = "the dyadic fixtures produce exactly representable readings, so the asserted \ + reason = "the dyadic fixtures produce exactly representable readings, and the asserted \ constants are exact contracts" )] diff --git a/libs/@local/graph/atlas/src/salt/projector/loss/penalty.rs b/libs/@local/graph/atlas/src/salt/projector/loss/penalty.rs index a13758aa856..31cf2393d28 100644 --- a/libs/@local/graph/atlas/src/salt/projector/loss/penalty.rs +++ b/libs/@local/graph/atlas/src/salt/projector/loss/penalty.rs @@ -23,8 +23,7 @@ pub(crate) enum Penalty { not(test), expect( dead_code, - reason = "the selected penalty for the planned calibration; consumed when the band \ - trainer is wired" + reason = "no production path constructs this penalty, and the tests select it" ) )] Identity, @@ -36,8 +35,7 @@ pub(crate) enum Penalty { not(test), expect( dead_code, - reason = "the unselected product-target alternative; the planned calibration selects \ - Identity" + reason = "no production path constructs this penalty, and the tests select it" ) )] QuadraticHinge, diff --git a/libs/@local/graph/atlas/src/salt/projector/loss/tests.rs b/libs/@local/graph/atlas/src/salt/projector/loss/tests.rs index 99f2a21a3af..54fd5caedb3 100644 --- a/libs/@local/graph/atlas/src/salt/projector/loss/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/loss/tests.rs @@ -78,18 +78,18 @@ fn affinity_energy(a: f32, b: f32, epsilon: f32) -> AffinityEnergy { /// A proximal energy with the given radius and temperature. fn proximal(radius: f32, temperature: f32) -> ProximalEnergy { - ProximalEnergy::new( - NonNegative::new(radius).expect("the test radius is non-negative"), - Positive::new(temperature).expect("the test temperature is positive"), - ) + ProximalEnergy { + radius: NonNegative::new(radius).expect("the test radius is non-negative"), + temperature: Positive::new(temperature).expect("the test temperature is positive"), + } } /// A coincident energy with the given radius and Huber threshold. fn coincident(radius: f32, threshold: f32) -> CoincidentEnergy { - CoincidentEnergy::new( - NonNegative::new(radius).expect("the test radius is non-negative"), - Positive::new(threshold).expect("the test threshold is positive"), - ) + CoincidentEnergy { + radius: NonNegative::new(radius).expect("the test radius is non-negative"), + threshold: Positive::new(threshold).expect("the test threshold is positive"), + } } /// Builds the relation mixture with scale guard `epsilon`. @@ -368,6 +368,34 @@ fn coincident_derivative_matches_finite_differences() { } } +#[test] +fn relation_energy_serde_requires_ordered_radii() { + for radius in [1.0, 2.0] { + let json = serde_json::json!({ + "coincident": {"radius": radius, "threshold": 1.0}, + "proximal": {"radius": 1.0, "temperature": 0.5}, + "epsilon": 0.25, + }); + serde_json::from_value::(json) + .expect_err("unordered radii should not deserialize"); + } + let json = serde_json::json!({ + "coincident": {"radius": 0.0, "threshold": 1.0}, + "proximal": {"radius": 1.0, "temperature": 0.5}, + "epsilon": 0.25, + }); + let energy: RelationEnergy = + serde_json::from_value(json.clone()).expect("strictly ordered radii"); + assert_eq!( + Some(energy), + RelationEnergy::new(coincident(0.0, 1.0), proximal(1.0, 0.5), positive!(0.25)) + ); + assert_eq!( + serde_json::to_value(energy).expect("finite coefficients"), + json + ); +} + #[test] fn relation_energy_requires_ordered_radii() { // The Coincident radius must lie strictly below the Proximal one. @@ -824,7 +852,7 @@ fn support_targets_reject_invalid_anchors() { row: BatchRowId::new(0), target: Vec2::new(1.0, -1.0), radius: non_negative!(0.5), - weight: 1.0, + weight: positive!(1.0), }; assert!(SupportTargets::::new(&[], &*DEVICE).is_none()); @@ -838,16 +866,6 @@ fn support_targets_reject_invalid_anchors() { ) .is_none() ); - assert!( - SupportTargets::::new( - &[BatchAnchor { - weight: -0.5, - ..valid - }], - &*DEVICE - ) - .is_none() - ); assert!(SupportTargets::::new(&[valid], &*DEVICE).is_some()); } @@ -869,17 +887,20 @@ fn support_fixture() -> ( row: BatchRowId::new(0), target: Vec2::new(0.25, 0.5), radius: non_negative!(0.75), - weight: 1.5, + weight: positive!(1.5), }, BatchAnchor { row: BatchRowId::new(2), target: Vec2::new(-2.0, 1.25), radius: non_negative!(1.5), - weight: 0.5, + weight: positive!(0.5), }, ]; let targets = SupportTargets::new(&anchors, &*DEVICE).expect("the fixture anchors are valid"); - let options = SupportOptions::new(positive!(1.0), positive!(0.25)); + let options = SupportOptions { + threshold: positive!(1.0), + epsilon: positive!(0.25), + }; (coordinates, targets, options) } @@ -952,10 +973,13 @@ fn support_term_is_finite_at_exact_coincidence() { row: BatchRowId::new(0), target: Vec2::new(0.5, -0.25), radius: non_negative!(0.75), - weight: 1.0, + weight: positive!(1.0), }]; let targets = SupportTargets::new(&anchors, &*DEVICE).expect("the fixture anchors are valid"); - let options = SupportOptions::new(positive!(1.0), positive!(0.25)); + let options = SupportOptions { + threshold: positive!(1.0), + epsilon: positive!(0.25), + }; let value = support_term(&coordinates, &targets, options, 1.0); let scalar = value diff --git a/libs/@local/graph/atlas/src/salt/projector/miner/mod.rs b/libs/@local/graph/atlas/src/salt/projector/miner/mod.rs index bfe2e56f845..55f6c724e23 100644 --- a/libs/@local/graph/atlas/src/salt/projector/miner/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/miner/mod.rs @@ -59,61 +59,15 @@ use crate::{ /// `maximum_weight · (1 - r / neighbours)^rank_exponent`: the nearest surviving false neighbour /// carries the full weight and the last admissible rank fades toward zero, satisfying the bounded /// rank-weight contract. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct MinerOptions { - neighbours: NonZero, - search_margin: NonZero, - maximum_weight: Positive, - rank_exponent: Positive, + pub neighbours: NonZero, + pub search_margin: NonZero, + pub maximum_weight: Positive, + pub rank_exponent: Positive, } impl MinerOptions { - /// Assembles a mining schedule. - /// - /// Every field arrives valid by construction, so no state this type can hold is invalid. - #[must_use] - pub(crate) const fn new( - neighbours: NonZero, - search_margin: NonZero, - maximum_weight: Positive, - rank_exponent: Positive, - ) -> Self { - Self { - neighbours, - search_margin, - maximum_weight, - rank_exponent, - } - } - - /// Returns the per-row admission quota `h`. - #[inline] - #[must_use] - pub(crate) const fn neighbours(self) -> NonZero { - self.neighbours - } - - /// Returns the search-quota multiplier over the admission quota. - #[inline] - #[must_use] - pub(crate) const fn search_margin(self) -> NonZero { - self.search_margin - } - - /// Returns the bound every rank weight stays within. - #[inline] - #[must_use] - pub(crate) const fn maximum_weight(self) -> f32 { - self.maximum_weight.get() - } - - /// Returns the rank-decay exponent. - #[inline] - #[must_use] - pub(crate) const fn rank_exponent(self) -> f32 { - self.rank_exponent.get() - } - /// Computes the weight of the candidate at closeness `rank`. /// /// Ranks lie below the quota, and rank zero carries the full bound. The real formula @@ -125,7 +79,7 @@ impl MinerOptions { fn weight(self, rank: usize) -> f32 { #[expect( clippy::cast_precision_loss, - reason = "ranks stay below the quota, far inside exact f32 integers" + reason = "rank and quota deliberately convert to f32 for the rank weight" )] let relative = rank as f32 / self.neighbours.get() as f32; @@ -251,7 +205,7 @@ where /// fills or the readout ends. The result is short when the examined candidates run out, and /// the search never widens. fn mine_row(&self, field: &SpatialField<'_, N>, row: N) -> Vec<(N, f32)> { - let quota = self.options.neighbours().get(); + let quota = self.options.neighbours.get(); let mut accepted = Vec::with_capacity(quota); diff --git a/libs/@local/graph/atlas/src/salt/projector/miner/tests.rs b/libs/@local/graph/atlas/src/salt/projector/miner/tests.rs index 2b91c39ae05..61e13384c4f 100644 --- a/libs/@local/graph/atlas/src/salt/projector/miner/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/miner/tests.rs @@ -5,8 +5,8 @@ #![expect( clippy::float_cmp, - reason = "fixture coordinates are small integers, so squared distances and dyadic rank \ - weights are exact in both the reference and the kd-tree path" + reason = "fixture coordinates are small integers, and squared distances and dyadic rank \ + weights are therefore exact in both the reference and the kd-tree path" )] use core::num::NonZero; @@ -37,12 +37,12 @@ fn nonzero(value: usize) -> NonZero { /// Miner options from plain neighbour count, margin, maximum weight and rank exponent. fn options(neighbours: usize, margin: usize, maximum_weight: f32, exponent: f32) -> MinerOptions { - MinerOptions::new( - nonzero(neighbours), - nonzero(margin), - Positive::new(maximum_weight).expect("test weight bounds are positive"), - Positive::new(exponent).expect("test exponents are positive"), - ) + MinerOptions { + neighbours: nonzero(neighbours), + search_margin: nonzero(margin), + maximum_weight: Positive::new(maximum_weight).expect("test weight bounds are positive"), + rank_exponent: Positive::new(exponent).expect("test exponents are positive"), + } } /// Builds a symmetric semantic graph from undirected weighted edges. @@ -128,8 +128,14 @@ fn relation_indexes( /// A hard channel tripping at the given evidence mass. fn hard_config(threshold: f32) -> ProtectionConfig { ProtectionConfig::new( - ChannelConfig::new(0.0, threshold).expect("the fixture channel is in domain"), - ChannelConfig::new(0.0, threshold).expect("the fixture channel is in domain"), + ChannelConfig { + floor: UnitFraction::ZERO, + threshold: NonNegative::new(threshold).expect("the threshold is non-negative"), + }, + ChannelConfig { + floor: UnitFraction::ZERO, + threshold: NonNegative::new(threshold).expect("the threshold is non-negative"), + }, true, ) .expect("the fixture channels are ordered") @@ -157,7 +163,7 @@ fn reference_mine( config: ProtectionConfig, options: MinerOptions, ) -> Vec> { - let quota = options.neighbours().get(); + let quota = options.neighbours.get(); (0..coordinates.len()) .map(|row| { let mut candidates: Vec<(f32, usize)> = (0..coordinates.len()) @@ -263,7 +269,7 @@ fn mined_rows_match_a_brute_force_reference() { // Every weight satisfies the bounded rank-weight contract. for &(_, weight) in expected { assert!(weight > 0.0); - assert!(weight <= options.maximum_weight()); + assert!(weight <= options.maximum_weight); } } diff --git a/libs/@local/graph/atlas/src/salt/projector/model/mod.rs b/libs/@local/graph/atlas/src/salt/projector/model/mod.rs index fb0f248ec4e..613a5e0f70b 100644 --- a/libs/@local/graph/atlas/src/salt/projector/model/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/model/mod.rs @@ -206,8 +206,8 @@ pub(crate) enum NodeRole { not(any(test, feature = "bench")), expect( dead_code, - reason = "no production path constructs this role yet; the variant count sizes the \ - trained role table, so retiring it is a model-format change" + reason = "no production path constructs this role, and the variant count sizes the \ + trained role table, which makes retiring it a model-format change" ) )] OntologyType, @@ -219,8 +219,8 @@ pub(crate) enum NodeRole { not(any(test, feature = "bench")), expect( dead_code, - reason = "no production path constructs this role yet; the variant count sizes the \ - trained role table, so retiring it is a model-format change" + reason = "no production path constructs this role, and the variant count sizes the \ + trained role table, which makes retiring it a model-format change" ) )] Other, @@ -255,10 +255,10 @@ const PROJECTED_DIMENSIONS: usize = 2; /// Every dimension that gives a [`Projector`] its shape. /// -/// All fields are construction-valid, so building a model from an architecture cannot fail. Width -/// and depth are benchmark axes - the defaults are the candidate the quality and throughput -/// criteria judge first, not validated optima. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +/// All fields are construction-valid, and building a model from an architecture cannot fail. Width +/// and depth are benchmark axes: the defaults are the candidate the quality and throughput +/// criteria judge first rather than validated optima. +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct Architecture { /// Hidden width of the stem and every residual block. pub width: NonZero = DEFAULT_WIDTH, diff --git a/libs/@local/graph/atlas/src/salt/projector/report/mod.rs b/libs/@local/graph/atlas/src/salt/projector/report/mod.rs index 68af60c5e69..0eb102711af 100644 --- a/libs/@local/graph/atlas/src/salt/projector/report/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/report/mod.rs @@ -8,7 +8,7 @@ #[expect( dead_code, - reason = "the replay report's CLI adapter is unbuilt; the commit registering that subcommand \ - consumes this module and deletes this expectation" + reason = "no CLI subcommand consumes the replay report, and nothing else in the crate reaches \ + this module" )] pub(crate) mod replay; diff --git a/libs/@local/graph/atlas/src/salt/projector/report/replay/design.rs b/libs/@local/graph/atlas/src/salt/projector/report/replay/design.rs index 77cf49b49b9..81242e6517b 100644 --- a/libs/@local/graph/atlas/src/salt/projector/report/replay/design.rs +++ b/libs/@local/graph/atlas/src/salt/projector/report/replay/design.rs @@ -4,25 +4,24 @@ use alloc::borrow::Cow; use core::num::NonZero; use super::{draw::DrawnSamples, error::ReplayError}; -use crate::{file::generation::Generation, salt::quality::metric::NeighbourhoodAggregate}; +use crate::{ + file::generation::Generation, math::nz, salt::quality::metric::NeighbourhoodAggregate, +}; // The defaults mirror the quality suite's probe: 256 queries against // the suite's anchor count, 4,096 comparisons and the same -// neighbourhood sizes and horizon factor, so a replay reading and a -// suite reading sit on comparable normalizers. -const DEFAULT_QUERIES: NonZero = - NonZero::new(256).expect("the default query count is nonzero"); -const DEFAULT_COMPARISONS: NonZero = - NonZero::new(4096).expect("the default comparison count is nonzero"); -const DEFAULT_CONTROLS: NonZero = - NonZero::new(256).expect("the default control count is nonzero"); -const DEFAULT_NEIGHBOURHOODS: &[NonZero] = &[ - NonZero::new(15).expect("the default neighbourhood sizes are nonzero"), - NonZero::new(30).expect("the default neighbourhood sizes are nonzero"), - NonZero::new(50).expect("the default neighbourhood sizes are nonzero"), -]; -const DEFAULT_HORIZON_FACTOR: NonZero = - NonZero::new(2).expect("the default horizon factor is nonzero"); +// neighbourhood sizes and horizon factor. A replay reading and a +// suite reading therefore share comparable normalizers. +/// The default arrival-query sample size. +const DEFAULT_QUERIES: NonZero = nz!(256); +/// The default size of the shared comparison universe. +const DEFAULT_COMPARISONS: NonZero = nz!(4096); +/// The default control sample size. +const DEFAULT_CONTROLS: NonZero = nz!(256); +/// The default neighbourhood sizes, applied to both estimands. +const DEFAULT_NEIGHBOURHOODS: &[NonZero] = &[nz!(15), nz!(30), nz!(50)]; +/// The default horizon multiplier of the intrusion and extrusion readings. +const DEFAULT_HORIZON_FACTOR: NonZero = nz!(2); /// Sampling and neighbourhood settings for one replay. /// @@ -68,10 +67,11 @@ pub(crate) struct ReplayInputs<'pair> { /// One neighbourhood size's validated design. /// -/// Construction is the validation: each empty aggregate is built here once against its -/// universe, and every pass clones a template instead of revalidating the triple. The estimand -/// template serves both estimands, because the entity and class universes come from one joint -/// draw of `comparisons` members each, so they share one cardinality. +/// Construction validates each empty aggregate against its universe once. Every pass clones a +/// template instead of revalidating the triple. Entity and class estimands use separate draws, +/// each jointly selecting comparison and control members before splitting them. Each comparison +/// universe contains `comparisons` members, allowing both estimands to reuse one template. +#[derive(Debug)] #[expect( clippy::min_ident_chars, reason = "k is the canonical neighbourhood-size name across the metric literature" diff --git a/libs/@local/graph/atlas/src/salt/projector/report/replay/draw.rs b/libs/@local/graph/atlas/src/salt/projector/report/replay/draw.rs index 6e8bac2a583..073c8be904e 100644 --- a/libs/@local/graph/atlas/src/salt/projector/report/replay/draw.rs +++ b/libs/@local/graph/atlas/src/salt/projector/report/replay/draw.rs @@ -51,6 +51,7 @@ pub(super) struct DrawSizes { } /// The seeded draws of both estimands. +#[derive(Debug)] pub(super) struct DrawnSamples { /// Sampled arrival indices, ascending. pub query_draw: Vec, diff --git a/libs/@local/graph/atlas/src/salt/projector/report/replay/error.rs b/libs/@local/graph/atlas/src/salt/projector/report/replay/error.rs index 80e414c14ab..355212885e7 100644 --- a/libs/@local/graph/atlas/src/salt/projector/report/replay/error.rs +++ b/libs/@local/graph/atlas/src/salt/projector/report/replay/error.rs @@ -369,7 +369,7 @@ impl ReplayError { domain", )), Self::EmptyArrivals => Some(fmt.write_str( - "the later generation contains no arrival, so no run can exercise the deployed \ + "the later generation contains no arrival, and no run can exercise the deployed \ path", )), Self::InsufficientStableRows { diff --git a/libs/@local/graph/atlas/src/salt/projector/report/replay/extract.rs b/libs/@local/graph/atlas/src/salt/projector/report/replay/extract.rs index d0195fb441a..ad4aaffb35e 100644 --- a/libs/@local/graph/atlas/src/salt/projector/report/replay/extract.rs +++ b/libs/@local/graph/atlas/src/salt/projector/report/replay/extract.rs @@ -6,6 +6,7 @@ use super::error::ReplayError; use crate::{ dataset::{PROJECTOR_DIMENSIONS, TemporalAxes}, file::{ + ArtifactFile as _, array::ArrayFile, generation::{Generation, GenerationId}, identity::read::IdentityFile, @@ -18,8 +19,9 @@ use crate::{ /// One generation's data columns, in the shape the partition consumes. /// -/// The wire coordinates arrive gathered per node row, so the columns share one indexing and a -/// fabricated corpus needs no base-order permutation. +/// The wire coordinates are gathered per node row before they enter. The columns therefore share +/// one indexing, and a fabricated corpus needs no base-order permutation. +#[derive(Debug)] pub(super) struct GenerationColumns<'run> { /// The generation's identity. id: GenerationId, @@ -194,7 +196,7 @@ impl GenerationArtifacts { GenerationColumns::new( id, generation.repository().metadata.snapshot.axes, - self.identities.ids(), + self.identities.keys(), IdSlice::from_raw(representations), wire_of_row, ) @@ -234,11 +236,11 @@ impl WireArtifacts<'_> { let positions = self .positions .column::() - .ok_or(ReplayError::InvalidPositions { generation })?; + .map_err(|_invalid| ReplayError::InvalidPositions { generation })?; let wire = self .wire .column::() - .ok_or(ReplayError::InvalidWireCoordinates { generation })?; + .map_err(|_invalid| ReplayError::InvalidWireCoordinates { generation })?; Ok(positions.iter().map(|&position| wire[position]).collect()) } @@ -283,12 +285,13 @@ impl EndpointArtifact { ) -> Result<&IdSlice, ReplayError> { self.file .column::() - .ok_or(ReplayError::InvalidEndpoints { generation }) + .map_err(|_invalid| ReplayError::InvalidEndpoints { generation }) } } #[cfg(test)] mod tests { + use core::assert_matches; use std::fs::File; use camino::Utf8PathBuf; @@ -300,7 +303,7 @@ mod tests { }; use crate::{ file::{ - WriteInto as _, + ArtifactFile as _, WriteInto as _, array::{ArrayVariant, ArrayWriter, Dim, SizedColumn}, }, identity::{BasePosition, NodeRowId}, @@ -386,7 +389,7 @@ mod tests { IdSlice::from_raw(&wire), ); - assert!(matches!( + assert_matches!( result, Err(ReplayError::Rows { identities: 0, @@ -394,7 +397,7 @@ mod tests { wire: 1, .. }), - )); + ); } /// Gathers each row's wire coordinate through a staged position permutation. @@ -459,10 +462,10 @@ mod tests { } .gathered(generation(1)); - assert!(matches!( + assert_matches!( result, Err(ReplayError::InvalidPositions { generation: named }) if named == generation(1), - )); + ); } /// Panics with the standard out-of-bounds message on a position beyond the wire column. @@ -514,9 +517,9 @@ mod tests { let artifact = EndpointArtifact { file: staged }; let result = artifact.pairs(generation(2)); - assert!(matches!( + assert_matches!( result, Err(ReplayError::InvalidEndpoints { generation: named }) if named == generation(2), - )); + ); } } diff --git a/libs/@local/graph/atlas/src/salt/projector/report/replay/population.rs b/libs/@local/graph/atlas/src/salt/projector/report/replay/population.rs index 65e0e255dc9..970d310f41f 100644 --- a/libs/@local/graph/atlas/src/salt/projector/report/replay/population.rs +++ b/libs/@local/graph/atlas/src/salt/projector/report/replay/population.rs @@ -55,7 +55,7 @@ hashql_core::id::newtype! { } /// One stable identity's rows in both generations. -#[derive(Copy, Clone)] +#[derive(Debug, Copy, Clone)] pub(super) struct StablePair { /// The identity's earlier-generation row. pub earlier_row: NodeRowId, @@ -79,7 +79,7 @@ pub(super) struct Populations { /// /// The representative is the class's lowest-later-row member: a deterministic rule, independent /// of any draw. -#[derive(Copy, Clone)] +#[derive(Debug, Copy, Clone)] pub(super) struct StableClass { /// The representative member's rows. pub representative: StablePair, diff --git a/libs/@local/graph/atlas/src/salt/projector/report/replay/preflight.rs b/libs/@local/graph/atlas/src/salt/projector/report/replay/preflight.rs index df8f4d62f32..136528f4996 100644 --- a/libs/@local/graph/atlas/src/salt/projector/report/replay/preflight.rs +++ b/libs/@local/graph/atlas/src/salt/projector/report/replay/preflight.rs @@ -170,7 +170,7 @@ impl Pair> { #[cfg(test)] mod tests { - use core::num::NonZero; + use core::assert_matches; use std::fs; use camino::Utf8PathBuf; @@ -201,7 +201,7 @@ mod tests { FitConfig { seed, selection: SelectionOptions { - maximum_count: NonZero::new(2).expect("the fixture capacity is nonzero"), + maximum_count: nz!(2), .. }, curve: AffinityCurve::new(positive!(1.577), positive!(0.895)), @@ -248,13 +248,13 @@ mod tests { } .agreed(); - assert!(matches!( + assert_matches!( result, Err(ReplayError::NotProjectorPlaced { generation: named, placement: Placement::LandmarkBaseline, }) if named == generation(1), - )); + ); } /// A pair whose embedder fingerprints differ fails with `EmbedderMismatch`. @@ -276,7 +276,7 @@ mod tests { } .agreed(); - assert!(matches!(result, Err(ReplayError::EmbedderMismatch { .. }))); + assert_matches!(result, Err(ReplayError::EmbedderMismatch { .. })); } /// A pair differing only in the config seed fails with `ConfigMismatch`. @@ -300,7 +300,7 @@ mod tests { } .agreed(); - assert!(matches!(result, Err(ReplayError::ConfigMismatch { .. }))); + assert_matches!(result, Err(ReplayError::ConfigMismatch { .. })); } /// Accepts a pair differing only in the prior lineage, which the contract excludes. @@ -392,7 +392,7 @@ mod tests { let mut hasher = Sha256::new(); hasher.update(b"other bytes"); let observed = hasher.finalize(); - assert!(matches!( + assert_matches!( result, Err(ReplayError::ArtifactIntegrity { generation: named, @@ -403,7 +403,7 @@ mod tests { && role == tampered.name && expected == tampered.hash && actual == observed, - )); + ); } /// A recorded artifact missing from the directory fails with `ReadArtifact` naming the role. @@ -417,9 +417,9 @@ mod tests { let result = VerifiedPair::verified_artifacts(generation(1), &directory, [missing]); - assert!(matches!( + assert_matches!( result, Err(ReplayError::ReadArtifact { role, .. }) if role == file_name("gone.arr"), - )); + ); } } diff --git a/libs/@local/graph/atlas/src/salt/projector/report/replay/tests.rs b/libs/@local/graph/atlas/src/salt/projector/report/replay/tests.rs index 19cf3abe317..55ceda06eda 100644 --- a/libs/@local/graph/atlas/src/salt/projector/report/replay/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/report/replay/tests.rs @@ -1,7 +1,7 @@ //! Unit suite over the data-level constructor, fakes only. use alloc::{borrow::Cow, collections::VecDeque}; -use core::num::NonZero; +use core::{assert_matches, num::NonZero}; use hashql_core::id::IdSlice; @@ -19,7 +19,7 @@ use crate::{ dataset::{PROJECTOR_DIMENSIONS, TemporalAxes}, file::generation::GenerationId, identity::{EdgeRowId, NodeRowId}, - math::{AlignedVecN, MatrixN, Vec2}, + math::{AlignedVecN, MatrixN, Vec2, nz}, progress::NoProgress, }; @@ -155,12 +155,10 @@ const NO_EDGES: &IdSlice = IdSlice::from_raw(&[]); /// The replay runs four queries and comparisons with one control and one neighbourhood of size one. fn one_neighbourhood() -> ReplaySizes { ReplaySizes { - queries: NonZero::new(4).expect("the fixture query cap is nonzero"), - comparisons: NonZero::new(4).expect("the fixture universe is nonzero"), - controls: NonZero::new(1).expect("the fixture control count is nonzero"), - neighbourhoods: Cow::Owned(vec![ - NonZero::new(1).expect("the fixture neighbourhood size is nonzero"), - ]), + queries: nz!(4), + comparisons: nz!(4), + controls: nz!(1), + neighbourhoods: Cow::Owned(vec![nz!(1)]), .. } } @@ -237,7 +235,7 @@ fn partition_standing_pair() { .stable .iter() .all(|pair| pair.earlier_row == pair.later_row && pair.later_row < row(5)), - "the five shared byte-equal identities sit on matching rows", + "the five shared byte-equal identities lie on matching rows", ); assert_eq!(populations.revised, 1); assert_eq!(populations.arrivals.len(), 2); @@ -317,10 +315,10 @@ fn axes_unrecorded() { &one_neighbourhood(), ); - assert!(matches!( + assert_matches!( result, Err(ReplayError::UnrecordedTemporalAxes { generation: named }) if named == generation(1), - )); + ); } #[test] @@ -337,9 +335,10 @@ fn pair_unordered() { &one_neighbourhood(), ); - assert!( - matches!(result, Err(ReplayError::OrderViolation { .. })), - "transaction times {earlier_at} and {later_at} must refuse", + assert_matches!( + result, + Err(ReplayError::OrderViolation { .. }), + "transaction times {earlier_at} and {later_at} must refuse" ); } } @@ -367,7 +366,7 @@ fn arrivals_empty() { &one_neighbourhood(), ); - assert!(matches!(result, Err(ReplayError::EmptyArrivals))); + assert_matches!(result, Err(ReplayError::EmptyArrivals)); } #[test] @@ -381,19 +380,19 @@ fn stable_insufficient() { STANDING_EDGES, 0, &ReplaySizes { - comparisons: NonZero::new(5).expect("the fixture universe is nonzero"), + comparisons: nz!(5), ..one_neighbourhood() }, ); - assert!(matches!( + assert_matches!( result, Err(ReplayError::InsufficientStableRows { stable: 5, comparisons: 5, controls: 1, }), - )); + ); } #[test] @@ -421,14 +420,14 @@ fn stable_classes_insufficient() { &one_neighbourhood(), ); - assert!(matches!( + assert_matches!( result, Err(ReplayError::InsufficientStableClasses { classes: 3, comparisons: 4, controls: 1, }), - )); + ); } /// A neighbourhood size of three over a universe of four fails with `NeighbourhoodDesign`. @@ -443,17 +442,15 @@ fn neighbourhood_oversized() { STANDING_EDGES, 0, &ReplaySizes { - neighbourhoods: Cow::Owned(vec![ - NonZero::new(3).expect("the fixture neighbourhood size is nonzero"), - ]), + neighbourhoods: Cow::Owned(vec![nz!(3)]), ..one_neighbourhood() }, ); - assert!(matches!( + assert_matches!( result, Err(ReplayError::NeighbourhoodDesign { universe: 4, .. }), - )); + ); } /// Reads every designed count, optimum and identity under the faithful planar projector. @@ -837,14 +834,14 @@ fn joint_sample_overflow() { }, ); - assert!(matches!( + assert_matches!( result, Err(ReplayError::InsufficientStableRows { stable: 5, comparisons: usize::MAX, controls: usize::MAX, }), - )); + ); } /// Admits a neighbourhood of size one over a universe of two and refuses it over one. @@ -853,14 +850,14 @@ fn joint_sample_overflow() { /// universe of one with `NeighbourhoodDesign`. #[test] fn horizon_design_refusal() { - let size = NonZero::new(1).expect("the neighbourhood size is nonzero"); - let factor = NonZero::new(2).expect("the factor is nonzero"); + let size = nz!(1); + let factor = nz!(2); NeighbourhoodDesign::new(size, 2, 2, factor).expect("a universe of two hosts a size of one"); - assert!(matches!( + assert_matches!( NeighbourhoodDesign::new(size, 1, 1, factor), Err(ReplayError::NeighbourhoodDesign { universe: 1, .. }), - )); + ); } /// A comparison count of `2³²` fails with `UniverseBeyondRankDomain` before any sampling refusal. @@ -884,21 +881,19 @@ fn universe_beyond_rank_domain_refusal() { }, ); - assert!(matches!( + assert_matches!( result, Err(ReplayError::UniverseBeyondRankDomain { comparisons }) if comparisons == 1_usize << 32, - )); + ); } /// Both derivation fixtures pin the metric wiring numerically and share these sizes. fn derivation_sizes() -> ReplaySizes { ReplaySizes { - queries: NonZero::new(1).expect("the fixture query cap is nonzero"), - comparisons: NonZero::new(4).expect("the fixture universe is nonzero"), - controls: NonZero::new(1).expect("the fixture control count is nonzero"), - neighbourhoods: Cow::Owned(vec![ - NonZero::new(1).expect("the fixture neighbourhood size is nonzero"), - ]), + queries: nz!(1), + comparisons: nz!(4), + controls: nz!(1), + neighbourhoods: Cow::Owned(vec![nz!(1)]), .. } } @@ -1022,9 +1017,9 @@ fn weighting_pair() -> (Corpus, Corpus) { #[expect( clippy::float_cmp, reason = "the comparisons are deliberately exact: each expected value either repeats the \ - kernel's own f64 operations, so both sides round identically, or reaches the same \ - bits through individually exact f64 steps, and every wire coordinate and designed \ - tie is exact in f32" + kernel's own f64 operations, and both sides then round identically, or reaches the \ + same bits through individually exact f64 steps, and every wire coordinate and \ + designed tie is exact in f32" )] fn metric_orientation() { // The stable rows are entities 1..=6 with rows 1 and 2 byte-equal, leaving five stable @@ -1187,7 +1182,7 @@ fn metric_orientation() { #[expect( clippy::float_cmp, reason = "the comparisons are deliberately exact: each expected expression repeats the \ - kernel's own f64 operations, so both sides round identically" + kernel's own f64 operations, and both sides therefore round identically" )] fn class_weighting() { // The stable population spreads seven rows over five classes: rows diff --git a/libs/@local/graph/atlas/src/salt/projector/sample/mod.rs b/libs/@local/graph/atlas/src/salt/projector/sample/mod.rs index 8f558166771..5b06ebf117e 100644 --- a/libs/@local/graph/atlas/src/salt/projector/sample/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/sample/mod.rs @@ -116,7 +116,7 @@ where let total = *self .cumulative .last() - .unwrap_or_else(|| unreachable!("cumulatative is always non empty")); + .unwrap_or_else(|| unreachable!("the cumulative table is never empty")); let mut pairs = Vec::with_capacity_in(count, alloc); diff --git a/libs/@local/graph/atlas/src/salt/projector/sample/tests.rs b/libs/@local/graph/atlas/src/salt/projector/sample/tests.rs index fbb35c38b84..c8b94fe548f 100644 --- a/libs/@local/graph/atlas/src/salt/projector/sample/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/sample/tests.rs @@ -1,8 +1,6 @@ //! Certificates for the minibatch samplers. //! //! Distribution laws, per-type limits under skew, veto admission, and seeded reproducibility. - -use core::num::NonZero; use std::alloc::Global; use hashql_core::id::Id as _; @@ -12,7 +10,7 @@ use rand_xoshiro::Xoshiro256PlusPlus; use super::{OrdinaryNegativeSampler, RelationEdgeSampler, SemanticEdgeSampler}; use crate::{ identity::{EdgeRowId, NodeRowId, OntologyRowId}, - math::{NonNegative, unit_fraction}, + math::{NonNegative, nz, unit_fraction}, salt::{ policy::ClassProbabilities, relation::{ @@ -223,7 +221,7 @@ fn relation_caps_bind_per_type_under_skew() { ); let sampler = RelationEdgeSampler::new(&indexes.attraction); - let cap = NonZero::new(3).expect("the cap is nonzero"); + let cap = nz!(3); let draws = sampler.sample_in(2, cap, rng(11), Global); assert_eq!(draws.len(), 2, "both types should participate"); @@ -258,7 +256,7 @@ fn relation_type_requests_beyond_the_index_return_every_group() { ); let sampler = RelationEdgeSampler::new(&indexes.attraction); - let draws = sampler.sample_in(64, NonZero::new(4).expect("nonzero"), rng(13), Global); + let draws = sampler.sample_in(64, nz!(4), rng(13), Global); let relations: Vec = draws .iter() @@ -278,7 +276,7 @@ fn relation_sampling_is_seeded() { .collect(); let indexes = relation_indexes(8, &policies, instances); let sampler = RelationEdgeSampler::new(&indexes.attraction); - let cap = NonZero::new(5).expect("nonzero"); + let cap = nz!(5); let draws = |seed: u64| { sampler diff --git a/libs/@local/graph/atlas/src/salt/projector/scale/frozen/mod.rs b/libs/@local/graph/atlas/src/salt/projector/scale/frozen/mod.rs index a59a99ae280..798ea03a1c6 100644 --- a/libs/@local/graph/atlas/src/salt/projector/scale/frozen/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/scale/frozen/mod.rs @@ -145,7 +145,7 @@ where /// wiring contracts checked in debug builds, since both artifacts come from one generation. #[expect( clippy::cast_possible_truncation, - reason = "the declared constant lives in the working f32 precision; the domain check \ + reason = "the declared constant lives in the working f32 precision, and the domain check \ reads the narrowed value" )] pub(crate) fn freeze( @@ -315,9 +315,8 @@ where not(test), expect( dead_code, - reason = "the band trainer's bounded-staleness check is the designed reader: the live \ - field re-measured over the frozen neighbour sets; that trainer is not yet \ - wired" + reason = "the live field re-measured over the frozen neighbour sets is the \ + bounded-staleness check, and nothing outside the tests calls it" ) )] pub(crate) fn live_scales( @@ -375,8 +374,8 @@ where not(test), expect( dead_code, - reason = "called by live_scales for the bounded-staleness check; tests are today's \ - readers" + reason = "read by live_scales for the bounded-staleness check, and nothing outside \ + the tests reaches either" ) )] pub(crate) const fn frozen_set(&self, row: N) -> &IdSlice @@ -503,13 +502,14 @@ fn positive_quantile( #[expect( clippy::cast_precision_loss, - reason = "row counts sit far below 2^53, so the count converts exactly" + reason = "row counts lie far below 2^53 and convert exactly" )] let mass = parameters.scale_quantile * positive.len() as f64; #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, - reason = "the quantile lies in (0, 1], so the ceiling lies in [1, len] and fits usize" + reason = "the quantile lies in (0, 1], and the ceiling therefore lies in [1, len] and \ + fits usize" )] let rank = mass.ceil() as usize; diff --git a/libs/@local/graph/atlas/src/salt/projector/scale/frozen/refusal.rs b/libs/@local/graph/atlas/src/salt/projector/scale/frozen/refusal.rs index 46c72bcec3b..b742d75f284 100644 --- a/libs/@local/graph/atlas/src/salt/projector/scale/frozen/refusal.rs +++ b/libs/@local/graph/atlas/src/salt/projector/scale/frozen/refusal.rs @@ -138,7 +138,7 @@ where "the boundary field's spread {spread} is not a strictly positive f32", ), Self::NoPositiveScale => fmt.write_str( - "every frozen local scale is zero, so the epsilon window has no upper bound to \ + "every frozen local scale is zero, and the epsilon window has no upper bound to \ read", ), Self::EmptyWindow { floor, ceiling } => write!( diff --git a/libs/@local/graph/atlas/src/salt/projector/scale/frozen/tests.rs b/libs/@local/graph/atlas/src/salt/projector/scale/frozen/tests.rs index fb269e75d9a..214dcc7bf32 100644 --- a/libs/@local/graph/atlas/src/salt/projector/scale/frozen/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/scale/frozen/tests.rs @@ -6,8 +6,7 @@ #![expect( clippy::float_cmp, - reason = "fixtures use exactly representable values, so the asserted constants are exact \ - contracts" + reason = "the asserted constants are exactly representable, and equality on them is exact" )] use core::assert_matches; @@ -426,5 +425,5 @@ fn the_quantile_reads_positive_scales_alone() { &twin_table(8).view(), params(0.25, 0.25), ) - .expect("the quantile reads the positive scales, so the window stays open"); + .expect("the window stays open because the quantile reads the positive scales alone"); } diff --git a/libs/@local/graph/atlas/src/salt/projector/scale/mod.rs b/libs/@local/graph/atlas/src/salt/projector/scale/mod.rs index 3442be4214b..38b16902fd4 100644 --- a/libs/@local/graph/atlas/src/salt/projector/scale/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/scale/mod.rs @@ -94,8 +94,8 @@ where /// defect. #[expect( clippy::panic_in_result_fn, - reason = "row-domain agreement is a wiring contract asserted at entry; the error channel \ - is reserved for diverged coordinates, a runtime condition" + reason = "row-domain agreement is a wiring contract asserted at entry, and the error \ + channel is reserved for diverged coordinates, a runtime condition" )] pub(crate) fn compute( coordinates: &FinitePointField, diff --git a/libs/@local/graph/atlas/src/salt/projector/train/batch/draw.rs b/libs/@local/graph/atlas/src/salt/projector/train/batch/draw.rs index df6c4868c14..b74cf2b2a09 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/batch/draw.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/batch/draw.rs @@ -13,7 +13,7 @@ use rand::Rng; use super::super::BatchPlan; use crate::{ identity::NodeRowId, - math::{NonNegative, Vec2}, + math::{NonNegative, Positive, Vec2}, random::sample_indices_vec, salt::{ landmark::artifact::LandmarkSkeleton, @@ -45,7 +45,7 @@ pub(crate) struct SupportAnchor { pub row: N, pub target: Vec2, pub radius: NonNegative, - pub weight: f32, + pub weight: Positive, } /// Computes one landmark's median layout distance to its nearest skeleton neighbours. @@ -94,7 +94,7 @@ impl SupportAnchor { /// guards the division. pub(crate) fn at_landmarks( skeleton: &LandmarkSkeleton, - weight: f32, + weight: Positive, mut class_of: impl FnMut(NodeRowId) -> N, ) -> Vec { let coordinates = skeleton.coordinates(); diff --git a/libs/@local/graph/atlas/src/salt/projector/train/batch/mod.rs b/libs/@local/graph/atlas/src/salt/projector/train/batch/mod.rs index aafdd50b8b6..89a7932ee92 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/batch/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/batch/mod.rs @@ -27,7 +27,7 @@ use hashql_core::id::{Id, IdSlice}; use crate::{ dataset::PROJECTOR_DIMENSIONS, - math::{AlignedVecN, NonNegative}, + math::{AlignedVecN, NonNegative, nz}, salt::{ projector::{ loss::{BatchAnchor, BatchRowId, RelationEdge, RelationEdges}, @@ -53,8 +53,7 @@ pub(crate) use self::draw::{BatchSampler, DrawContext, Populations, SupportAncho /// /// Padded rows replicate the last participating row, and no population references them. They /// receive exactly zero force and contribute exactly zero parameter gradient. -pub(crate) const ROW_ALIGNMENT: NonZero = - NonZero::new(256).expect("the row alignment is non-zero"); +pub(crate) const ROW_ALIGNMENT: NonZero = nz!(256); /// The per-row model input columns of one corpus. /// diff --git a/libs/@local/graph/atlas/src/salt/projector/train/fit/error.rs b/libs/@local/graph/atlas/src/salt/projector/train/fit/error.rs index 1be09bc07c9..2e721312ad4 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/fit/error.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/fit/error.rs @@ -228,15 +228,15 @@ where fmt.write_str("the semantic graph carries no edge weight to train against") } Self::UnbaselinedRadius => fmt.write_str( - "the boundary sits at step zero, so the Proximal radius would be measured on an \ - untrained map; give the opening segment steps", + "the boundary lies at step zero, and the Proximal radius would be measured on an \ + untrained map: give the opening segment steps", ), Self::MissingProximalReviews => fmt.write_str( "the attraction index carries Proximal force but no reviewed-Proximal verdict \ covers any of it; confirm Proximal types in review", ), Self::CoincidentWithoutProximal => fmt.write_str( - "the attraction index carries Coincident force but no Proximal force, so no \ + "the attraction index carries Coincident force but no Proximal force, and no \ reviewed-Proximal measurement can set the radius the relation energy composes \ with; train with the relation evidence withheld", ), @@ -248,7 +248,7 @@ where Self::Refresh(error) => error.fmt(fmt), Self::Step(error) => error.fmt(fmt), Self::ScheduleChanged { .. } => fmt.write_str( - "the resumed schedule differs from the one the opening segment ran under; resume \ + "the resumed schedule differs from the one the opening segment ran under: resume \ with the schedule the checkpoint was trained under", ), Self::Ruler(error) => error.fmt(fmt), @@ -263,11 +263,11 @@ where "the declared canonical step index {step} lies outside the training curriculum", ), Self::EmptyTargetPopulation => fmt.write_str( - "the target estimand's declared unit population carries no mass; the run belongs \ - to the vacuous-record taxonomy", + "the target estimand's declared unit population carries no mass, and the run is \ + vacuous", ), Self::TargetWithoutUnitDraws => fmt.write_str( - "the target objective needs relation-type draws and the plan draws none; give the \ + "the target objective needs relation-type draws and the plan draws none: give the \ plan a positive relation-type count", ), Self::PenaltyWithoutForceAtEquality => fmt.write_str( diff --git a/libs/@local/graph/atlas/src/salt/projector/train/fit/fixture.rs b/libs/@local/graph/atlas/src/salt/projector/train/fit/fixture.rs index 7da3411f7a3..62ccb95ff2b 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/fit/fixture.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/fit/fixture.rs @@ -11,7 +11,7 @@ use rand::SeedableRng as _; use rand_xoshiro::Xoshiro256PlusPlus; use super::{ - RelationLens, TrainOptions, TrainerInputs, TrainingSchedule, + RelationLens, TrainOptions, TrainerInputs, TrainingSchedule, TrainingScheduleOptions, objective::{GaugeDraw, TargetInputs, TargetOptions, TargetSplit}, }; use crate::{ @@ -253,31 +253,38 @@ pub(super) fn corpus_with( row: NodeRowId::new(0), target: Vec2::new(-1.0, 0.0), radius: non_negative!(1.0), - weight: 1.0, + weight: positive!(1.0), }, SupportAnchor { row: NodeRowId::from_usize(HALF), target: Vec2::new(1.0, 0.0), radius: non_negative!(1.0), - weight: 1.0, + weight: positive!(1.0), }, ], verdicts, } } +/// A schedule of `steps` with the fixture's learning rates. +/// +/// # Panics +/// +/// Panics when the arguments do not form a valid schedule. +#[expect(clippy::ok_expect, reason = "Result::expect is not const")] pub(super) const fn schedule( steps: NonZero, boundary: usize, refresh_interval: NonZero, ) -> TrainingSchedule { - TrainingSchedule::new( + TrainingSchedule::new(TrainingScheduleOptions { steps, boundary, refresh_interval, - positive_unit_fraction!(0.05), - unit_fraction!(0.001), - ) + initial_learning_rate: positive_unit_fraction!(0.05), + minimum_learning_rate: unit_fraction!(0.001), + }) + .ok() .expect("the fixture schedule is valid") } @@ -301,24 +308,35 @@ pub(super) fn options(schedule: TrainingSchedule) -> TrainOptions { positive!(0.5), ) .expect("the fixture exponent satisfies the objective bound"), - support: SupportOptions::new(positive!(1.0), positive!(0.5)), + support: SupportOptions { + threshold: positive!(1.0), + epsilon: positive!(0.5), + }, budget: Budget { floor: positive!(0.25), }, - coefficients: Coefficients::new( - Positive::ONE, - non_negative!(0.5), - non_negative!(0.5), - NonNegative::ONE, - NonNegative::ZERO, - NonNegative::ONE, - ), - miner: MinerOptions::new(nz!(2), nz!(2), positive!(1.0), positive!(1.0)), - lens: RelationLens::new( - CoincidentEnergy::new(non_negative!(0.0), positive!(1.0)), - positive!(0.25), - positive!(0.5), - ), + coefficients: Coefficients { + semantic: Positive::ONE, + ordinary: non_negative!(0.5), + hard: non_negative!(0.5), + relation: NonNegative::ONE, + anchor: NonNegative::ZERO, + landmark: NonNegative::ONE, + }, + miner: MinerOptions { + neighbours: nz!(2), + search_margin: nz!(2), + maximum_weight: positive!(1.0), + rank_exponent: positive!(1.0), + }, + lens: RelationLens { + coincident: CoincidentEnergy { + radius: non_negative!(0.0), + threshold: positive!(1.0), + }, + temperature: positive!(0.25), + epsilon: positive!(0.5), + }, forward_rows: nz!(3), } } diff --git a/libs/@local/graph/atlas/src/salt/projector/train/fit/mod.rs b/libs/@local/graph/atlas/src/salt/projector/train/fit/mod.rs index 30b4104a17a..cbf1c8440cf 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/fit/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/fit/mod.rs @@ -58,7 +58,10 @@ pub(crate) use self::{ error::{TargetRefusal, TargetRefusalCause, TrainError}, evidence::{BoundaryEvidence, FrozenRadius, RefreshFraction, TickTelemetry, TrainingEvidence}, inputs::TrainerInputs, - options::{RelationLens, TrainOptions, TrainingSchedule}, + options::{ + RelationLens, TrainOptions, TrainingSchedule, TrainingScheduleError, + TrainingScheduleOptions, + }, }; use super::metrics::BudgetBreakdown; use crate::{ @@ -89,8 +92,8 @@ pub(crate) struct Model { #[derive(Debug)] #[expect( clippy::large_enum_variant, - reason = "the outcome is constructed and consumed once per run, so the size difference never \ - rides a hot path" + reason = "the outcome is constructed and consumed once per run, and the size difference is \ + never on a hot path" )] pub(crate) enum FitOutcome { /// A completed run's trained model beside its evidence. @@ -241,16 +244,23 @@ impl> BoundaryState { let recorder = NamedMpkBytesRecorder::::new(); let record: ResumeRecord = recorder.load(bytes, device)?; - let schedule = NonZero::new(record.steps) + let schedule_options = NonZero::new(record.steps) .zip(NonZero::new(record.refresh_interval)) .zip( PositiveUnitFraction::new(record.initial_learning_rate) .zip(UnitFraction::new(record.minimum_learning_rate)), ) - .and_then(|((steps, refresh_interval), (initial, minimum))| { - TrainingSchedule::new(steps, record.boundary, refresh_interval, initial, minimum) - }) - .ok_or(CheckpointError::InvalidSchedule)?; + .map( + |((steps, refresh_interval), (initial, minimum))| TrainingScheduleOptions { + steps, + boundary: record.boundary, + refresh_interval, + initial_learning_rate: initial, + minimum_learning_rate: minimum, + }, + ) + .ok_or(CheckpointError::MalformedSchedule)?; + let schedule = TrainingSchedule::new(schedule_options)?; // The scheduler advances once per step and reads its position before use. After the // opening segment's `boundary` steps it is therefore at `boundary - 1`. A boundary of diff --git a/libs/@local/graph/atlas/src/salt/projector/train/fit/options.rs b/libs/@local/graph/atlas/src/salt/projector/train/fit/options.rs index e4761c7293d..1d12156aa09 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/fit/options.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/fit/options.rs @@ -4,7 +4,7 @@ //! The types here validate that configuration at construction, and the run consumes plain //! values and re-checks nothing step to step. -use core::num::NonZero; +use core::{fmt, num::NonZero}; use crate::{ math::{ @@ -19,10 +19,76 @@ use crate::{ }, }; +/// A training schedule violated a cross-field constraint. +#[derive(Debug)] +pub(crate) enum TrainingScheduleError { + /// The initial learning rate is below the minimum learning rate. + InitialLearningRateSmallerThanMinimum { + /// The configured initial learning rate. + initial: PositiveUnitFraction, + /// The configured minimum learning rate. + minimum: UnitFraction, + }, + /// The phase boundary lies beyond the run. + BoundaryGreaterThanSteps { + /// The configured run length. + steps: NonZero, + /// The configured phase boundary. + boundary: usize, + }, +} + +impl fmt::Display for TrainingScheduleError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InitialLearningRateSmallerThanMinimum { initial, minimum } => { + write!( + fmt, + "the minimum learning rate must be less than or equal to the initial learning \ + rate: initial={initial}, minimum={minimum}" + ) + } + Self::BoundaryGreaterThanSteps { steps, boundary } => { + write!( + fmt, + "the boundary must be less than or equal to the number of steps: \ + steps={steps}, boundary={boundary}" + ) + } + } + } +} + +impl core::error::Error for TrainingScheduleError {} + +/// Raw schedule fields admitted by [`TrainingSchedule::new`]. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct TrainingScheduleOptions { + /// The run length in steps. + pub steps: NonZero, + /// The phase-boundary step index. + pub boundary: usize, + /// The refresh cadence in steps. + pub refresh_interval: NonZero, + /// The cosine schedule's opening learning rate. + pub initial_learning_rate: PositiveUnitFraction, + /// The cosine schedule's floor learning rate. + pub minimum_learning_rate: UnitFraction, +} + +impl TryFrom for TrainingSchedule { + type Error = TrainingScheduleError; + + fn try_from(value: TrainingScheduleOptions) -> Result { + Self::new(value) + } +} + /// A validated step schedule. /// /// Run length, phase boundary, refresh cadence, and the learning-rate envelope. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "TrainingScheduleOptions")] pub(crate) struct TrainingSchedule { steps: NonZero, boundary: usize, @@ -39,24 +105,33 @@ impl TrainingSchedule { /// opens the ladder: the run is semantic-only and records no boundary evidence. Refresh ticks /// run at step zero and every `refresh_interval` steps after it. /// - /// Returns [`None`] unless the boundary lies within the run and the rates satisfy the cosine - /// schedule's domain: both are unit fractions by type, the initial rate is strictly positive, - /// and the minimum does not exceed it. - #[must_use] + /// # Errors + /// + /// Returns [`TrainingScheduleError`] for misordered learning rates or a boundary beyond the + /// run. pub(crate) const fn new( - steps: NonZero, - boundary: usize, - refresh_interval: NonZero, - initial_learning_rate: PositiveUnitFraction, - minimum_learning_rate: UnitFraction, - ) -> Option { - let rates = minimum_learning_rate <= initial_learning_rate; - - if !(boundary <= steps.get() && rates) { - return None; + TrainingScheduleOptions { + steps, + boundary, + refresh_interval, + initial_learning_rate, + minimum_learning_rate, + }: TrainingScheduleOptions, + ) -> Result { + if minimum_learning_rate > initial_learning_rate { + return Err( + TrainingScheduleError::InitialLearningRateSmallerThanMinimum { + initial: initial_learning_rate, + minimum: minimum_learning_rate, + }, + ); + } + + if boundary > steps.get() { + return Err(TrainingScheduleError::BoundaryGreaterThanSteps { steps, boundary }); } - Some(Self { + Ok(Self { steps, boundary, refresh_interval, @@ -71,15 +146,15 @@ impl TrainingSchedule { /// ratified schedule's shape. The learning-rate envelope and the refresh cadence stay the /// ratified ones. #[must_use] - pub(crate) const fn shortened(steps: NonZero) -> Self { - Self::new( + pub(crate) fn shortened(steps: NonZero) -> Self { + Self::new(TrainingScheduleOptions { steps, - steps.get().div_euclid(2), - nz!(250), - positive_unit_fraction!(1.0e-3), - unit_fraction!(1.0e-5), - ) - .expect("the ratified schedule domain admits any step count") + boundary: steps.get().div_euclid(2), + refresh_interval: nz!(250), + initial_learning_rate: positive_unit_fraction!(1.0e-3), + minimum_learning_rate: unit_fraction!(1.0e-5), + }) + .expect("a halved boundary and fixed ordered rates should form a valid schedule") } /// Returns the run length in steps. @@ -120,54 +195,17 @@ impl TrainingSchedule { /// The validated relation-lens constants the boundary composes with. /// -/// The Coincident energy arrives fully configured - its radius is a configuration value until a -/// reviewed-Coincident calibration exists. The boundary measures only the Proximal radius, while -/// `temperature` and the scale guard `epsilon` complete the composed energy. -#[derive(Debug, Copy, Clone, PartialEq)] +/// The Coincident energy arrives fully configured: its radius is a configuration value, and no +/// calibration measures it. The boundary measures only the Proximal radius, while `temperature` +/// and the scale guard `epsilon` complete the composed energy. +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct RelationLens { - coincident: CoincidentEnergy, - temperature: Positive, - epsilon: Positive, + pub coincident: CoincidentEnergy, + pub temperature: Positive, + pub epsilon: Positive, } impl RelationLens { - /// Composes the lens constants. - /// - /// Every field arrives valid by type, so the composition is plain wiring. - #[must_use] - pub(crate) const fn new( - coincident: CoincidentEnergy, - temperature: Positive, - epsilon: Positive, - ) -> Self { - Self { - coincident, - temperature, - epsilon, - } - } - - /// Returns the configured Coincident energy. - #[inline] - #[must_use] - pub(crate) const fn coincident(self) -> CoincidentEnergy { - self.coincident - } - - /// Returns the Proximal transition temperature. - #[inline] - #[must_use] - pub(crate) const fn temperature(self) -> Positive { - self.temperature - } - - /// Returns the local-scale guard. - #[inline] - #[must_use] - pub(crate) const fn epsilon(self) -> Positive { - self.epsilon - } - /// Composes the relation energy at a Proximal radius. /// /// The Proximal energy takes the radius at the lens temperature, and the configured @@ -176,7 +214,11 @@ impl RelationLens { /// [`RelationEnergy::new`] requires. #[must_use] pub(crate) fn energy(self, radius: NonNegative) -> Option { - let proximal = ProximalEnergy::new(radius, self.temperature); + let proximal = ProximalEnergy { + radius, + temperature: self.temperature, + }; + RelationEnergy::new(self.coincident, proximal, self.epsilon) } } diff --git a/libs/@local/graph/atlas/src/salt/projector/train/fit/session/boundary.rs b/libs/@local/graph/atlas/src/salt/projector/train/fit/session/boundary.rs index 6cf5b548d64..e5c301b7b7a 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/fit/session/boundary.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/fit/session/boundary.rs @@ -51,7 +51,7 @@ where ScaledFrame::new(frame, &scales), calibration_options(self.options), ); - warn_boundary_findings(&calibration, self.options.lens.temperature()); + warn_boundary_findings(&calibration, self.options.lens.temperature); let (frozen, radius) = match calibration.radius() { Some(radius) => (radius, FrozenRadius::Measured { radius }), @@ -61,14 +61,14 @@ where None => return Err(TrainError::MissingProximalReviews), }; - let energy = - self.options - .lens - .energy(frozen) - .ok_or_else(|| TrainError::DegenerateRadius { - radius: frozen, - coincident: self.options.lens.coincident().radius(), - })?; + let energy = self + .options + .lens + .energy(frozen) + .ok_or(TrainError::DegenerateRadius { + radius: frozen, + coincident: self.options.lens.coincident.radius, + })?; Ok(( energy, @@ -85,8 +85,8 @@ where pub(super) const fn calibration_options(options: &TrainOptions) -> CalibrationOptions { CalibrationOptions::new( options.plan.relation_cap, - options.lens.epsilon(), - options.lens.temperature(), + options.lens.epsilon, + options.lens.temperature, ) } diff --git a/libs/@local/graph/atlas/src/salt/projector/train/fit/session/mod.rs b/libs/@local/graph/atlas/src/salt/projector/train/fit/session/mod.rs index c9e80ce09ff..787a2f768a3 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/fit/session/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/fit/session/mod.rs @@ -383,7 +383,7 @@ where self.inputs.attraction, ScaledFrame::new(frame, &tables[0]), calibration_options(self.options), - energy.proximal().radius(), + energy.proximal().radius, ) } diff --git a/libs/@local/graph/atlas/src/salt/projector/train/fit/session/training.rs b/libs/@local/graph/atlas/src/salt/projector/train/fit/session/training.rs index 4914f1f94e8..781d70b2c88 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/fit/session/training.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/fit/session/training.rs @@ -67,8 +67,8 @@ pub(crate) fn scheduler(schedule: TrainingSchedule) -> CosineAnnealingLrSchedule // No Debug: the optimizer adaptor inside `Training` does not implement it. #[expect( clippy::large_enum_variant, - reason = "the outcome is constructed and consumed once per run segment, so the size \ - difference never rides a hot path" + reason = "the outcome is constructed and consumed once per run segment, and the size \ + difference is never on a hot path" )] pub(crate) enum RunOutcome> { /// The segment completed and the training state advanced. diff --git a/libs/@local/graph/atlas/src/salt/projector/train/fit/tests.rs b/libs/@local/graph/atlas/src/salt/projector/train/fit/tests.rs index c3c66fdfc80..873ce858e40 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/fit/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/fit/tests.rs @@ -53,7 +53,12 @@ use crate::{ loss::{Penalty, UnitLaw}, model::{Architecture, Projector}, scale::frozen::{FrozenRuler, RulerParameters}, - train::{Coefficients, refresh, step::LossBreakdown}, + train::{ + Coefficients, + fit::{TrainingScheduleError, TrainingScheduleOptions}, + refresh, + step::LossBreakdown, + }, verdict::{ ResolvedVerdict, calibrate::{ @@ -207,14 +212,14 @@ fn landmark_support_keeps_the_frame() { let corpus = semantic_corpus(); let mut options = options(schedule(nz!(25), 25, nz!(10))); // A dominant landmark coefficient pins the anchored rows. - options.coefficients = Coefficients::new( - Positive::ONE, - non_negative!(0.5), - non_negative!(0.5), - NonNegative::ONE, - NonNegative::ZERO, - non_negative!(8.0), - ); + options.coefficients = Coefficients { + semantic: Positive::ONE, + ordinary: non_negative!(0.5), + hard: non_negative!(0.5), + relation: NonNegative::ONE, + anchor: NonNegative::ZERO, + landmark: non_negative!(8.0), + }; let fitted = fit( model(), &corpus.inputs(), @@ -253,16 +258,16 @@ fn few_steps_semantic_gradient_pulls_cluster_mates_together() { // The default fixture carries other non-zero coefficients (both repulsion terms, the landmark // term, and the evidence-less relation term). Zeroing every force but the semantic one isolates // the mechanism the certificate names. Rows in one cluster share almost the same input - // representation, so the shared network weights let any other active force move cluster mates - // in a correlated way, and that confound must be off for the certificate to test what it names. - options.coefficients = Coefficients::new( - Positive::ONE, - NonNegative::ZERO, - NonNegative::ZERO, - NonNegative::ZERO, - NonNegative::ZERO, - NonNegative::ZERO, - ); + // representation: the shared network weights let any other active force move cluster mates in + // a correlated way, and that confound must be off for the certificate to test what it names. + options.coefficients = Coefficients { + semantic: Positive::ONE, + ordinary: NonNegative::ZERO, + hard: NonNegative::ZERO, + relation: NonNegative::ZERO, + anchor: NonNegative::ZERO, + landmark: NonNegative::ZERO, + }; let fitted = fit( model(), &corpus.inputs(), @@ -307,14 +312,14 @@ fn few_steps_landmark_force_points_anchors_at_their_targets() { // certificate reads. The relation term has no evidence in this corpus and goes to zero with // them. The semantic term's type admits no zero: the 8:1 landmark dominance carries the // isolation. - options.coefficients = Coefficients::new( - Positive::ONE, - NonNegative::ZERO, - NonNegative::ZERO, - NonNegative::ZERO, - NonNegative::ZERO, - non_negative!(8.0), - ); + options.coefficients = Coefficients { + semantic: Positive::ONE, + ordinary: NonNegative::ZERO, + hard: NonNegative::ZERO, + relation: NonNegative::ZERO, + anchor: NonNegative::ZERO, + landmark: non_negative!(8.0), + }; let fitted = fit( model(), &corpus.inputs(), @@ -719,7 +724,7 @@ fn chunked_forwards_match_the_whole_corpus_pass() { .expect("the fixture model is finite"); assert_eq!( chunked, whole, - "rows project independently, so slicing cannot change the frame" + "slicing cannot change the frame because rows project independently" ); } @@ -732,46 +737,82 @@ fn chunked_forwards_match_the_whole_corpus_pass() { fn schedule_validates_its_domain() { // Out-of-range rates are unconstructible: the initial rate as a `PositiveUnitFraction`, the // minimum as a `UnitFraction`. The residual domain here is the boundary and the rate ordering. - let valid = TrainingSchedule::new( - nz!(10), - 5, - nz!(2), - positive_unit_fraction!(0.05), - unit_fraction!(0.001), + TrainingSchedule::new(TrainingScheduleOptions { + steps: nz!(10), + boundary: 5, + refresh_interval: nz!(2), + initial_learning_rate: positive_unit_fraction!(0.05), + minimum_learning_rate: unit_fraction!(0.001), + }) + .expect("should be a valid schedule"); + + assert_matches!( + TrainingSchedule::new(TrainingScheduleOptions { + steps: nz!(10), + boundary: 11, + refresh_interval: nz!(2), + initial_learning_rate: positive_unit_fraction!(0.05), + minimum_learning_rate: unit_fraction!(0.001), + }), + Err(TrainingScheduleError::BoundaryGreaterThanSteps { .. }), ); - assert!(valid.is_some()); - assert!( - TrainingSchedule::new( - nz!(10), - 11, - nz!(2), - positive_unit_fraction!(0.05), - unit_fraction!(0.001) - ) - .is_none(), - "the boundary lies within the run" + + TrainingSchedule::new(TrainingScheduleOptions { + steps: nz!(10), + boundary: 5, + refresh_interval: nz!(2), + initial_learning_rate: positive_unit_fraction!(0.05), + minimum_learning_rate: unit_fraction!(0.0), + }) + .expect("a zero minimum decays the rate to nothing and is lawful"); + + assert_matches!( + TrainingSchedule::new(TrainingScheduleOptions { + steps: nz!(10), + boundary: 5, + refresh_interval: nz!(2), + initial_learning_rate: positive_unit_fraction!(0.05), + minimum_learning_rate: unit_fraction!(0.1) + }), + Err(TrainingScheduleError::InitialLearningRateSmallerThanMinimum { .. }) ); +} + +#[test] +fn schedule_deserialization_cross_field_constraints() { + let schedule = TrainingSchedule::new(TrainingScheduleOptions { + steps: nz!(10), + boundary: 5, + refresh_interval: nz!(2), + initial_learning_rate: positive_unit_fraction!(0.05), + minimum_learning_rate: unit_fraction!(0.001), + }) + .expect("the fixture schedule is valid"); + let document = serde_json::to_value(schedule).expect("the schedule serializes"); + assert_eq!( + serde_json::from_value::(document.clone()) + .expect("the unchanged schedule should deserialize"), + schedule, + ); + + let mut boundary = document.clone(); + boundary["boundary"] = serde_json::json!(11); + let error = serde_json::from_value::(boundary) + .expect_err("a boundary beyond the run refuses to parse"); assert!( - TrainingSchedule::new( - nz!(10), - 5, - nz!(2), - positive_unit_fraction!(0.05), - unit_fraction!(0.0) - ) - .is_some(), - "a zero minimum decays the rate to nothing and is lawful" + error + .to_string() + .contains("boundary must be less than or equal") ); + + let mut rates = document; + rates["minimum_learning_rate"] = serde_json::json!(0.1); + let error = serde_json::from_value::(rates) + .expect_err("a minimum above the initial rate refuses to parse"); assert!( - TrainingSchedule::new( - nz!(10), - 5, - nz!(2), - positive_unit_fraction!(0.05), - unit_fraction!(0.1) - ) - .is_none(), - "the minimum does not exceed the initial rate" + error + .to_string() + .contains("minimum learning rate must be less than or equal"), ); } @@ -878,14 +919,14 @@ fn forked_ladders_share_the_frozen_radius() { ) .expect("the resume checkpoint opens"); let mut cell = opening; - cell.coefficients = Coefficients::new( - Positive::ONE, - non_negative!(0.5), - non_negative!(0.5), + cell.coefficients = Coefficients { + semantic: Positive::ONE, + ordinary: non_negative!(0.5), + hard: non_negative!(0.5), relation, - NonNegative::ZERO, - NonNegative::ONE, - ); + anchor: NonNegative::ZERO, + landmark: NonNegative::ONE, + }; fit_from_boundary( state, &corpus.inputs(), @@ -1960,10 +2001,8 @@ fn open_checkpoint_invalid_schedule() { ) else { panic!("a minimum above the initial rate should be rejected"); }; - assert!( - matches!(error, CheckpointError::InvalidSchedule), - "the rejection should name the schedule: {error}" - ); + + assert_matches!(error, CheckpointError::InvalidSchedule(_)); } /// A record whose scheduler position is off the boundary fails with `SchedulerPosition`. diff --git a/libs/@local/graph/atlas/src/salt/projector/train/metrics.rs b/libs/@local/graph/atlas/src/salt/projector/train/metrics.rs index cf941e9af78..7e238a3f0c5 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/metrics.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/metrics.rs @@ -190,8 +190,8 @@ impl BudgetBreakdown { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn overall(&self) -> &BudgetSummary { @@ -203,8 +203,8 @@ impl BudgetBreakdown { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) fn types(&self) -> impl Iterator { @@ -220,8 +220,8 @@ impl BudgetBreakdown { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn deciles(&self) -> &[BudgetSummary; Decile::COUNT] { @@ -307,8 +307,8 @@ impl DisplacementMoments { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn count(&self) -> u64 { @@ -322,8 +322,8 @@ impl DisplacementMoments { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn sum(&self) -> DNonNegative { @@ -337,8 +337,8 @@ impl DisplacementMoments { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn sum_squares(&self) -> DNonNegative { @@ -352,8 +352,8 @@ impl DisplacementMoments { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn maximum(&self) -> NonNegative { @@ -396,8 +396,8 @@ impl DisplacementHistogram { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn counts(&self) -> &[u64; EXPONENT_BUCKETS] { @@ -411,8 +411,8 @@ impl DisplacementHistogram { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn moments(&self) -> &DisplacementMoments { @@ -501,8 +501,8 @@ impl DisplacementSummary { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn overall(&self) -> &DisplacementHistogram { @@ -514,8 +514,8 @@ impl DisplacementSummary { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) fn types(&self) -> impl Iterator { @@ -531,8 +531,8 @@ impl DisplacementSummary { not(test), expect( dead_code, - reason = "the generation evidence's training stats are the designed reader; writing \ - them into the generation metadata is registered wiring work" + reason = "the generation evidence's training stats are the designed reader, and \ + nothing writes them into the generation metadata" ) )] pub(crate) const fn deciles(&self) -> &[DisplacementHistogram; Decile::COUNT] { diff --git a/libs/@local/graph/atlas/src/salt/projector/train/mod.rs b/libs/@local/graph/atlas/src/salt/projector/train/mod.rs index f963120caba..0aff3cedb9b 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/mod.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/mod.rs @@ -128,80 +128,19 @@ impl Error for StepError where N: fmt::Debug + fmt::Display {} /// coefficient is finite and non-negative. /// /// The relation coefficient is the lens-independent factor. The training loop multiplies it by the -/// step's step, so a zero step contributes nothing regardless of the configured value. -#[derive(Debug, Copy, Clone, PartialEq)] +/// training step's lens value `η`, and a zero lens contributes nothing regardless of the +/// configured value. +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct Coefficients { - semantic: Positive, - ordinary: NonNegative, - hard: NonNegative, - relation: NonNegative, - anchor: NonNegative, - landmark: NonNegative, + pub semantic: Positive, + pub ordinary: NonNegative, + pub hard: NonNegative, + pub relation: NonNegative, + pub anchor: NonNegative, + pub landmark: NonNegative, } impl Coefficients { - /// Assembles objective coefficients. - #[must_use] - pub(crate) const fn new( - semantic: Positive, - ordinary: NonNegative, - hard: NonNegative, - relation: NonNegative, - anchor: NonNegative, - landmark: NonNegative, - ) -> Self { - Self { - semantic, - ordinary, - hard, - relation, - anchor, - landmark, - } - } - - /// Returns the semantic attraction coefficient. - #[inline] - #[must_use] - pub(crate) const fn semantic(self) -> Positive { - self.semantic - } - - /// Returns the ordinary repulsion coefficient. - #[inline] - #[must_use] - pub(crate) const fn ordinary(self) -> NonNegative { - self.ordinary - } - - /// Returns the hard-negative repulsion coefficient. - #[inline] - #[must_use] - pub(crate) const fn hard(self) -> NonNegative { - self.hard - } - - /// Returns the lens-independent relation coefficient. - #[inline] - #[must_use] - pub(crate) const fn relation(self) -> NonNegative { - self.relation - } - - /// Returns the temporal-anchor support coefficient. - #[inline] - #[must_use] - pub(crate) const fn anchor(self) -> NonNegative { - self.anchor - } - - /// Returns the landmark support coefficient. - #[inline] - #[must_use] - pub(crate) const fn landmark(self) -> NonNegative { - self.landmark - } - /// Normalizes the coefficient bases by their objective masses. /// /// Semantic and ordinary by the total semantic edge weight, hard by the corpus row count, and @@ -240,17 +179,17 @@ impl Coefficients { .narrow_lossy() }; - Self::new( - Positive::new(scaled(self.semantic.into(), weight.get()).get()).expect( + Self { + semantic: Positive::new(scaled(self.semantic.into(), weight.get()).get()).expect( "a quotient leaves the positive domain only for a weight total more than 38 \ orders from its base", ), - scaled(self.ordinary, weight.get()), - scaled(self.hard, rows as f64), - self.relation, - scaled(self.anchor, anchor_pool as f64), - scaled(self.landmark, landmark_pool as f64), - ) + ordinary: scaled(self.ordinary, weight.get()), + hard: scaled(self.hard, rows as f64), + relation: self.relation, + anchor: scaled(self.anchor, anchor_pool as f64), + landmark: scaled(self.landmark, landmark_pool as f64), + } } } @@ -259,7 +198,7 @@ impl Coefficients { /// A zero count disables its family for the run. The semantic draw and the relation cap are /// structurally positive because a batch without semantic pairs cannot train and a zero cap would /// admit no edges from a selected type. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct BatchPlan { /// Semantic positive pairs per step, drawn weight-proportionally. pub semantic_pairs: NonZero, @@ -282,7 +221,7 @@ pub(crate) struct BatchPlan { /// Every field is a validated value. The struct is plain wiring. The relation energy is absent /// exactly while the run has no frozen Proximal radius - the opening semantic-only segment - and /// the loop supplies it when the ladder opens. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct ObjectiveOptions { /// The semantic affinity energy shared by attraction and both repulsion families. pub affinity: AffinityEnergy, diff --git a/libs/@local/graph/atlas/src/salt/projector/train/refresh.rs b/libs/@local/graph/atlas/src/salt/projector/train/refresh.rs index 847a0eae4e2..837469ac407 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/refresh.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/refresh.rs @@ -295,6 +295,38 @@ where } } +/// Replaces `frame` with projected coordinates, including any non-finite output. +pub(crate) fn forward_unchecked_in>( + model: &Projector, + columns: NodeColumns<'_, N>, + eta: NonNegative, + forward_rows: NonZero, + device: &B::Device, + frame: &mut IdVec, +) where + N: Id, +{ + frame.clear(); + let rows = columns.representations.len(); + let mut start = 0; + + while start < rows { + let end = (start + forward_rows.get()).min(rows); + let coordinates = model.forward(columns.input_range(start..end, eta, device)); + + let data = coordinates.into_data(); + let values = data + .as_slice::() + .expect("the projector's coordinates are an f32 tensor"); + + let points = + Vec2::from_slice(values).expect("a [rows, 2] tensor reads back an even length"); + + frame.extend_from_slice(IdSlice::from_raw(points)); + start = end; + } +} + /// Projects the whole corpus at one step, in bounded row slices. /// /// `forward_rows` bounds each slice's row count, and with it the peak device memory of a corpus diff --git a/libs/@local/graph/atlas/src/salt/projector/train/tests.rs b/libs/@local/graph/atlas/src/salt/projector/train/tests.rs index 13b9775b644..f7114da7371 100644 --- a/libs/@local/graph/atlas/src/salt/projector/train/tests.rs +++ b/libs/@local/graph/atlas/src/salt/projector/train/tests.rs @@ -7,8 +7,6 @@ reason = "dyadic fixture values compute exactly in f32 and bit-exact assertions are the \ contract" )] - -use core::num::NonZero; use std::sync::{LazyLock, Mutex}; use burn::{ @@ -180,8 +178,14 @@ fn affinity() -> AffinityEnergy { /// is `0.5`: unit normalization comes from local scales of `0.5`. fn relation_energy() -> RelationEnergy { RelationEnergy::new( - CoincidentEnergy::new(non_negative!(0.25), positive!(1.0)), - ProximalEnergy::new(non_negative!(1.0), positive!(0.5)), + CoincidentEnergy { + radius: non_negative!(0.25), + threshold: positive!(1.0), + }, + ProximalEnergy { + radius: non_negative!(1.0), + temperature: positive!(0.5), + }, positive!(0.5), ) .expect("the fixture radii are ordered") @@ -189,7 +193,10 @@ fn relation_energy() -> RelationEnergy { /// Support options with a unit Huber threshold and a `0.5` radius floor. fn support_options() -> SupportOptions { - SupportOptions::new(positive!(1.0), positive!(0.5)) + SupportOptions { + threshold: positive!(1.0), + epsilon: positive!(0.5), + } } /// Coefficients used by the objective fixtures. @@ -197,14 +204,14 @@ fn support_options() -> SupportOptions { /// `λ_S = 0.5` pairs with a semantic scale of two for a unit semantic factor, and `λ_N = 2` /// doubles the ordinary term, which keeps the two families distinguishable in the combined field. fn coefficients() -> Coefficients { - Coefficients::new( - positive!(0.5), - non_negative!(2.0), - NonNegative::ONE, - NonNegative::ONE, - NonNegative::ONE, - NonNegative::ONE, - ) + Coefficients { + semantic: positive!(0.5), + ordinary: non_negative!(2.0), + hard: NonNegative::ONE, + relation: NonNegative::ONE, + anchor: NonNegative::ONE, + landmark: NonNegative::ONE, + } } /// Assembles the objective options with the given relation energy and budget. @@ -333,10 +340,10 @@ fn draws_are_deterministic_at_a_fixed_seed() { vec![instance(0, 7, 0, 1), instance(1, 9, 4, 5)], ); let plan = BatchPlan { - semantic_pairs: NonZero::new(8).expect("eight is non-zero"), + semantic_pairs: nz!(8), ordinary_pairs: 4, relation_types: 1, - relation_cap: NonZero::new(4).expect("four is non-zero"), + relation_cap: nz!(4), hard_queries: 0, landmark_anchors: 0, temporal_anchors: 0, @@ -401,10 +408,10 @@ fn allocator_seam_draws_and_assembles_identically() { vec![instance(0, 7, 0, 1), instance(1, 9, 4, 5)], ); let plan = BatchPlan { - semantic_pairs: NonZero::new(8).expect("eight is non-zero"), + semantic_pairs: nz!(8), ordinary_pairs: 4, relation_types: 1, - relation_cap: NonZero::new(4).expect("four is non-zero"), + relation_cap: nz!(4), hard_queries: 0, landmark_anchors: 1, temporal_anchors: 1, @@ -422,13 +429,13 @@ fn allocator_seam_draws_and_assembles_identically() { row: NodeRowId::new(3), target: Vec2::new(0.5, -0.25), radius: non_negative!(1.0), - weight: 1.0, + weight: positive!(1.0), }, SupportAnchor { row: NodeRowId::new(1), target: Vec2::new(-0.5, 0.75), radius: non_negative!(2.0), - weight: 0.5, + weight: positive!(0.5), }, ]; @@ -502,10 +509,10 @@ fn draw_skips_the_relation_family_at_a_zero_step() { let graph = semantic_graph(4, &[(0, 1, 0.5)]); let indexes = relation_indexes(4, &[proximal_policy(7)], vec![instance(0, 7, 2, 3)]); let plan = BatchPlan { - semantic_pairs: NonZero::new(4).expect("four is non-zero"), + semantic_pairs: nz!(4), ordinary_pairs: 0, relation_types: 1, - relation_cap: NonZero::new(4).expect("four is non-zero"), + relation_cap: nz!(4), hard_queries: 0, landmark_anchors: 0, temporal_anchors: 0, @@ -551,10 +558,10 @@ fn draw_computes_the_estimator_scales() { vec![instance(0, 7, 0, 1), instance(1, 9, 2, 3)], ); let plan = BatchPlan { - semantic_pairs: NonZero::new(8).expect("eight is non-zero"), + semantic_pairs: nz!(8), ordinary_pairs: 4, relation_types: 1, - relation_cap: NonZero::new(4).expect("four is non-zero"), + relation_cap: nz!(4), hard_queries: 0, landmark_anchors: 2, temporal_anchors: 0, @@ -573,19 +580,19 @@ fn draw_computes_the_estimator_scales() { row: NodeRowId::new(0), target: Vec2::new(0.0, 0.0), radius: non_negative!(1.0), - weight: 1.0, + weight: positive!(1.0), }, SupportAnchor { row: NodeRowId::new(1), target: Vec2::new(1.0, 0.0), radius: non_negative!(1.0), - weight: 1.0, + weight: positive!(1.0), }, SupportAnchor { row: NodeRowId::new(2), target: Vec2::new(2.0, 0.0), radius: non_negative!(1.0), - weight: 1.0, + weight: positive!(1.0), }, ]; let populations = sampler.draw( @@ -644,20 +651,20 @@ fn draw_collects_pooled_mined_pairs() { graph.view(), indexes.protection.view(), ProtectionConfig::default(), - MinerOptions::new( - NonZero::new(2).expect("two is non-zero"), - NonZero::new(2).expect("two is non-zero"), - Positive::ONE, - Positive::ONE, - ), + MinerOptions { + neighbours: nz!(2), + search_margin: nz!(2), + maximum_weight: Positive::ONE, + rank_exponent: Positive::ONE, + }, ); let frame = miner.mine(&field); let plan = BatchPlan { - semantic_pairs: NonZero::new(4).expect("four is non-zero"), + semantic_pairs: nz!(4), ordinary_pairs: 0, relation_types: 0, - relation_cap: NonZero::new(1).expect("one is non-zero"), + relation_cap: nz!(1), hard_queries: 4, landmark_anchors: 0, temporal_anchors: 0, @@ -716,7 +723,7 @@ fn assemble_reindexes_into_the_local_domain() { row: NodeRowId::new(5), target: Vec2::new(0.25, -0.5), radius: non_negative!(1.0), - weight: 1.0, + weight: positive!(1.0), }]; populations.landmark_scale = 1.0; @@ -965,7 +972,7 @@ fn support_terms_ride_autodiff_outside_the_budget() { row: NodeRowId::new(1), target: Vec2::new(2.0, 0.0), radius: non_negative!(0.5), - weight: 1.0, + weight: positive!(1.0), }]; populations.landmark_scale = 1.0; let batch = Batch::assemble(populations, None); @@ -1396,7 +1403,7 @@ fn padding_zero_force_at_simd_scale() { row: NodeRowId::new(4), target: Vec2::new(1.0, 0.0), radius: non_negative!(1.0), - weight: 1.0, + weight: positive!(1.0), }]; populations.landmark_scale = 1.0; let batch = Batch::assemble(populations, Some(&scales)); @@ -1566,7 +1573,7 @@ fn landmark(row: usize) -> SupportAnchor { row: NodeRowId::from_usize(row), target: Vec2::ZERO, radius: non_negative!(1.0), - weight: 1.0, + weight: positive!(1.0), } } diff --git a/libs/@local/graph/atlas/src/salt/quality/clump.rs b/libs/@local/graph/atlas/src/salt/quality/clump.rs index 46bdf235f9a..7bcc9e6180c 100644 --- a/libs/@local/graph/atlas/src/salt/quality/clump.rs +++ b/libs/@local/graph/atlas/src/salt/quality/clump.rs @@ -313,8 +313,7 @@ impl ClumpAggregate { /// An empty aggregate reads 1. #[expect( clippy::cast_precision_loss, - reason = "probe neighbourhood sizes and query counts are bounded orders of magnitude \ - below the f64 mantissa" + reason = "supported integer totals may round when converted to f64" )] #[must_use] pub(crate) fn recall(&self) -> UnitFraction { diff --git a/libs/@local/graph/atlas/src/salt/quality/metric.rs b/libs/@local/graph/atlas/src/salt/quality/metric.rs index 1c2532817d2..e887742e190 100644 --- a/libs/@local/graph/atlas/src/salt/quality/metric.rs +++ b/libs/@local/graph/atlas/src/salt/quality/metric.rs @@ -34,8 +34,7 @@ #![expect( clippy::cast_precision_loss, clippy::cast_possible_truncation, - reason = "probe universes, neighbourhood sizes, and query counts are bounded orders of \ - magnitude below both u32 and the f64 mantissa" + reason = "rank positions must fit u32. Integer totals may round when converted to f64" )] #![expect( clippy::min_ident_chars, @@ -161,8 +160,7 @@ impl NeighbourhoodAggregate { #[expect( clippy::integer_division, clippy::integer_division_remainder_used, - reason = "k and (2m - 3k + 1) never share odd parity, so halving the worst-case penalty \ - is exact" + reason = "k and (2m - 3k + 1) never share odd parity. Halving their even product is exact" )] #[must_use] pub(crate) fn supports(&self, observations: usize) -> bool { @@ -174,8 +172,8 @@ impl NeighbourhoodAggregate { .checked_mul(3) .and_then(|tripled| doubled.checked_sub(tripled - 1)) .expect( - "construction bounds the neighbourhood within half the universe, so the span \ - arithmetic cannot overflow or underflow", + "construction bounds the neighbourhood within half the universe. The doubled \ + universe already fits usize. The span arithmetic cannot overflow or underflow", ); let Some(worst) = self.k.checked_mul(span) else { return false; @@ -428,8 +426,7 @@ impl NeighbourhoodAggregate { #[expect( clippy::integer_division, clippy::integer_division_remainder_used, - reason = "k and (2m - 3k + 1) never share odd parity, so halving the worst-case penalty \ - is exact" + reason = "k and (2m - 3k + 1) never share odd parity. Halving their even product is exact" )] fn normalized(&self, penalty: u64) -> UnitFraction { if self.queries == 0 { diff --git a/libs/@local/graph/atlas/src/salt/quality/mod.rs b/libs/@local/graph/atlas/src/salt/quality/mod.rs index c9a82d6155b..f5643ab7dbc 100644 --- a/libs/@local/graph/atlas/src/salt/quality/mod.rs +++ b/libs/@local/graph/atlas/src/salt/quality/mod.rs @@ -53,7 +53,7 @@ impl QualityMetric { /// Every admission metric, in report-control order. #[expect( clippy::cast_possible_truncation, - reason = "the index runs over the variant count, an order of magnitude inside u8" + reason = "the six implicit discriminants fit in u8" )] pub const ALL: [Self; core::mem::variant_count::()] = // SAFETY: a fieldless `repr(u8)` enum has u8 size and requires a valid discriminant. These diff --git a/libs/@local/graph/atlas/src/salt/quality/probe/options.rs b/libs/@local/graph/atlas/src/salt/quality/probe/options.rs index 746c5be6bab..83b6a9a3e8e 100644 --- a/libs/@local/graph/atlas/src/salt/quality/probe/options.rs +++ b/libs/@local/graph/atlas/src/salt/quality/probe/options.rs @@ -4,42 +4,30 @@ use alloc::borrow::Cow; use core::num::NonZero; use super::error::ProbeError; +use crate::math::nz; -// The neighbourhood sizes match the suite's measured evidence: -// whole-probe readings of 0.883, 0.890 and 0.893 at -// k = 15, 30 and 50, the representation baseline of one run over the -// 985,932-row development corpus (2,196,562 edges, 49 types, 1,024 -// anchors and 4,096 comparisons at seed 0). Reading at -// those sizes compares against that record without interpolation. -// The record anchors the sizes and the scale, not any threshold: it -// is one generation under the landmark-baseline placement rather -// than the trained projector, and the default thresholds gate -// evidence presence rather than fidelity. The anchor and comparison -// defaults bound the canonical fetch (anchors + comparisons rows of -// 3,072 f32 components, ~53 MB) while keeping subgroup cells at a few -// dozen anchors and the sampled neighbourhoods well inside the -// aggregate's k ≤ m/2 domain. -const DEFAULT_ANCHORS: NonZero = - NonZero::new(256).expect("the default anchor count is nonzero"); -const DEFAULT_COMPARISONS: NonZero = - NonZero::new(4096).expect("the default comparison count is nonzero"); -const DEFAULT_NEIGHBOURHOODS: &[NonZero] = &[ - NonZero::new(15).expect("the default neighbourhood sizes are nonzero"), - NonZero::new(30).expect("the default neighbourhood sizes are nonzero"), - NonZero::new(50).expect("the default neighbourhood sizes are nonzero"), -]; -const DEFAULT_HORIZON_FACTOR: NonZero = - NonZero::new(2).expect("the default horizon factor is nonzero"); -// 64 shared pairs over 256 anchors read 16,384 triplet verdicts, but -// the design crosses the samples rather than drawing them -// independently: one pair sample serves every anchor and one anchor -// sample serves every pair, so the -// mean's error does not shrink as 1/√16,384. The pair-driven variance -// component shrinks only with the 64 pairs, which bounds the standard -// error at 0.5/√64 = 0.0625 of agreement in the worst case - 16× the -// 0.5/√16,384 = 0.0039 that reading the triplet count as independent -// draws suggests. How much of the verdict variance is pair-driven is -// not measured. +// recorded representation-baseline readings over the 985,932-row development corpus were 0.883, +// 0.890 and 0.893 at k = 15, 30 and 50 (2,196,562 edges, 49 types, 1,024 anchors, 4,096 +// comparisons, seed 0). Keeping those sizes permits comparison without interpolation. That one +// landmark-baseline generation motivates the reporting scale, not fidelity thresholds. the defaults +// request (256 + 4,096) · 3,072 f32 canonical components, about 53.5 MB of payload, with k well +// inside the aggregate's k ≤ m/2 domain. The uniform anchor sample supplies no minimum count for +// any subgroup. +/// The default anchor sample size. +const DEFAULT_ANCHORS: NonZero = nz!(256); +/// The default size of the shared comparison universe. +const DEFAULT_COMPARISONS: NonZero = nz!(4096); +/// The default neighbourhood sizes, in reporting order. +const DEFAULT_NEIGHBOURHOODS: &[NonZero] = &[nz!(15), nz!(30), nz!(50)]; +/// The default horizon multiplier of the intrusion and extrusion readings. +const DEFAULT_HORIZON_FACTOR: NonZero = nz!(2); +// The pair draws are independent conditional on fixed anchors and comparison rows, but all anchors +// reuse each pair. Averaging one pair's verdicts across anchors gives a value in [0, 1] with +// variance at most 1/4. Therefore averaging 64 independent pair means has conditional standard +// error at most 0.5/√64 = 0.0625, not the 0.5/√16,384 = 0.00390625 bound for 256 · 64 independent +// verdicts. This bounds the pair-sampling contribution, not the additional uncertainty from anchor +// and comparison sampling. +/// The default shared triplet-pair sample size. const DEFAULT_TRIPLET_PAIRS: usize = 64; /// Sampling and neighbourhood settings for one probe. diff --git a/libs/@local/graph/atlas/src/salt/quality/runner.rs b/libs/@local/graph/atlas/src/salt/quality/runner.rs index f6acbabb3d2..7a62a83ba92 100644 --- a/libs/@local/graph/atlas/src/salt/quality/runner.rs +++ b/libs/@local/graph/atlas/src/salt/quality/runner.rs @@ -23,7 +23,7 @@ use super::{ use crate::{ dataset::{Dataset, PROJECTOR_DIMENSIONS}, file::{ - array::ArrayFile, generation::Generation, identity::read::IdentityFile, + ArtifactFile as _, array::ArrayFile, generation::Generation, identity::read::IdentityFile, sprs::read::SprsFile, }, identity::NodeRowId, @@ -104,7 +104,7 @@ pub(crate) async fn run( .map_err(QualityRunError::OpenIdentities)?, ) .map_err(QualityRunError::InvalidIdentities)?; - let node_ids = identities.ids(); + let node_ids = identities.keys(); let view = knn.view(); #[expect( diff --git a/libs/@local/graph/atlas/src/salt/quality/tests.rs b/libs/@local/graph/atlas/src/salt/quality/tests.rs index 5a04f74574f..1977564d7e9 100644 --- a/libs/@local/graph/atlas/src/salt/quality/tests.rs +++ b/libs/@local/graph/atlas/src/salt/quality/tests.rs @@ -43,7 +43,7 @@ use crate::{ integrity::{Sha256, Update as _}, math::{ AffinityCurve, AlignedVecN, BoxedVecN, FinitePointField, NonNegative, UnitFraction, Vec2, - VecN, non_negative, positive, + VecN, non_negative, nz, positive, }, progress::NoProgress, salt::{ @@ -52,7 +52,8 @@ use crate::{ knn::table::Knn, landmark::select::SelectionOptions, policy::classifier::{ - FitConfig as ClassifierFitConfig, TrainingRow, TrainingSet, fit as fit_classifier, + FitConfig as ClassifierFitConfig, FitOptions as ClassifierFitOptions, TrainingRow, + TrainingSet, fit as fit_classifier, }, }, }; @@ -65,18 +66,24 @@ fn clump_fixture() -> Knn { let indptr: Vec = vec![0, 2, 4, 6, 8, 10, 12]; let indices: Vec = vec![1, 2, 0, 2, 0, 1, 4, 5, 3, 5, 3, 4]; let distances: Vec = vec![ + // 0 → 1, 2 + non_negative!(0.05), + non_negative!(0.08), + // 1 → 0, 2 non_negative!(0.05), - non_negative!(0.08), // 0 → 1, 2 non_negative!(0.05), - non_negative!(0.05), // 1 → 0, 2 + // 2 → 0, 1 non_negative!(0.08), - non_negative!(0.05), // 2 → 0, 1 + non_negative!(0.05), + // 3 → 4, 5 non_negative!(0.0), - non_negative!(1.5), // 3 → 4, 5 + non_negative!(1.5), + // 4 → 3, 5 non_negative!(0.0), - non_negative!(1.4), // 4 → 3, 5 + non_negative!(1.4), + // 5 → 3, 4 non_negative!(1.5), - non_negative!(1.4), // 5 → 3, 4 + non_negative!(1.4), ]; let matrix = sprs::CsMatI::new((6, 6), indptr, indices, distances); Knn::new(matrix).expect("the fixture satisfies every table invariant") @@ -183,31 +190,27 @@ fn hand_built_labels_read_like_a_grouping() { // label 1 matches twice, label 0 once, and unmatched labels 2 and 3 earn no credit #[test] fn clump_aggregate_counts_multiset_overlap() { - let mut aggregate = ClumpAggregate::new(NonZero::new(4).expect("nonzero")); + let mut aggregate = ClumpAggregate::new(nz!(4)); aggregate.observe(&mut [0, 1, 1, 2], &mut [1, 1, 3, 0]); assert_eq!(aggregate.queries(), 1); assert_eq!(aggregate.recall(), 3.0 / 4.0); // A second query merges into the running totals: 1 of 4 matched. - let mut second = ClumpAggregate::new(NonZero::new(4).expect("nonzero")); + let mut second = ClumpAggregate::new(nz!(4)); second.observe(&mut [5, 5, 5, 5], &mut [5, 6, 7, 8]); aggregate.merge(&second); assert_eq!(aggregate.queries(), 2); assert_eq!(aggregate.recall(), 4.0 / 8.0); // An empty aggregate reads 1, like the rank kernel's recall. - assert_eq!( - ClumpAggregate::new(NonZero::new(4).expect("nonzero")).recall(), - 1.0, - ); + assert_eq!(ClumpAggregate::new(nz!(4)).recall(), 1.0,); } #[test] fn identical_orderings_are_perfect() { let ordering: Vec = (0..10).collect(); - let mut aggregate = NeighbourhoodAggregate::new(10, NonZero::new(3).expect("nonzero"), 6) - .expect("3 <= 10 / 2 and 3 <= 6"); + let mut aggregate = NeighbourhoodAggregate::new(10, nz!(3), 6).expect("3 <= 10 / 2 and 3 <= 6"); let mut scratch = RankScratch::new(10); aggregate.observe(&ordering, &ordering, &mut scratch); @@ -224,8 +227,7 @@ fn identical_orderings_are_perfect() { fn reversed_ordering_is_worst() { let reference: Vec = (0..8).collect(); let map: Vec = (0..8).rev().collect(); - let mut aggregate = NeighbourhoodAggregate::new(8, NonZero::new(2).expect("nonzero"), 4) - .expect("2 <= 8 / 2 and 2 <= 4"); + let mut aggregate = NeighbourhoodAggregate::new(8, nz!(2), 4).expect("2 <= 8 / 2 and 2 <= 4"); let mut scratch = RankScratch::new(8); aggregate.observe(&reference, &map, &mut scratch); @@ -248,8 +250,7 @@ fn hand_computed_partial_agreement() { // k = 2: map top-2 = {0, 2}, reference top-2 = {0, 1}. let reference: Vec = (0..6).collect(); let map = [0, 2, 1, 3, 4, 5]; - let mut aggregate = NeighbourhoodAggregate::new(6, NonZero::new(2).expect("nonzero"), 4) - .expect("2 <= 6 / 2 and 2 <= 4"); + let mut aggregate = NeighbourhoodAggregate::new(6, nz!(2), 4).expect("2 <= 6 / 2 and 2 <= 4"); let mut scratch = RankScratch::new(6); aggregate.observe(&reference, &map, &mut scratch); @@ -272,8 +273,7 @@ fn horizon_splits_reshuffles_from_intruders() { // banishes point 1 to map position 5 in return. let reference: Vec = (0..6).collect(); let map = [0, 5, 2, 3, 4, 1]; - let mut aggregate = NeighbourhoodAggregate::new(6, NonZero::new(2).expect("nonzero"), 4) - .expect("2 <= 6 / 2 and 2 <= 4"); + let mut aggregate = NeighbourhoodAggregate::new(6, nz!(2), 4).expect("2 <= 6 / 2 and 2 <= 4"); let mut scratch = RankScratch::new(6); aggregate.observe(&reference, &map, &mut scratch); @@ -289,8 +289,7 @@ fn horizon_splits_reshuffles_from_intruders() { #[test] fn aggregate_pools_queries() { let reference: Vec = (0..6).collect(); - let mut aggregate = NeighbourhoodAggregate::new(6, NonZero::new(2).expect("nonzero"), 4) - .expect("2 <= 6 / 2 and 2 <= 4"); + let mut aggregate = NeighbourhoodAggregate::new(6, nz!(2), 4).expect("2 <= 6 / 2 and 2 <= 4"); let mut scratch = RankScratch::new(6); aggregate.observe(&reference, &reference, &mut scratch); @@ -391,16 +390,15 @@ fn observe_ranks_matches_observe() { let by_map = [2_u32, 4, 1, 5, 0, 6, 7, 3]; let mut through_orderings = - NeighbourhoodAggregate::new(8, NonZero::new(3).expect("nonzero"), 5) - .expect("3 <= 8 / 2 and 3 <= 5 <= 8"); + NeighbourhoodAggregate::new(8, nz!(3), 5).expect("3 <= 8 / 2 and 3 <= 5 <= 8"); let mut scratch = RankScratch::new(8); through_orderings.observe(&by_reference, &by_map, &mut scratch); // The same query as opposite-rank vectors, read off by hand: map // top-3 = {2, 4, 1} at reference positions 3, 0, 5; reference // top-3 = {4, 0, 6} at map positions 1, 4, 5. - let mut through_ranks = NeighbourhoodAggregate::new(8, NonZero::new(3).expect("nonzero"), 5) - .expect("3 <= 8 / 2 and 3 <= 5 <= 8"); + let mut through_ranks = + NeighbourhoodAggregate::new(8, nz!(3), 5).expect("3 <= 8 / 2 and 3 <= 5 <= 8"); through_ranks.observe_ranks(&[3, 0, 5], &[1, 4, 5]); assert_eq!(through_orderings, through_ranks); @@ -413,7 +411,7 @@ fn merged_aggregates_match_joint_observation() { let reversed: Vec = (0..6).rev().collect(); let mut scratch = RankScratch::new(6); - let two = NonZero::new(2).expect("nonzero"); + let two = nz!(2); let mut joint = NeighbourhoodAggregate::new(6, two, 4).expect("2 <= 6 / 2 and 2 <= 4"); joint.observe(&reference, &swapped, &mut scratch); joint.observe(&reference, &reversed, &mut scratch); @@ -850,8 +848,7 @@ fn flag_fixture(hits: &[bool]) -> ProbeReadings { .iter() .map(|&hit| { let mut aggregate = - NeighbourhoodAggregate::new(8, NonZero::new(1).expect("nonzero"), 2) - .expect("1 <= 8 / 2 and 1 <= 2 <= 8"); + NeighbourhoodAggregate::new(8, nz!(1), 2).expect("1 <= 8 / 2 and 1 <= 2 <= 8"); let rank = if hit { [0] } else { [7] }; aggregate.observe_ranks(&rank, &rank); vec![aggregate] @@ -861,7 +858,7 @@ fn flag_fixture(hits: &[bool]) -> ProbeReadings { ProbeReadings { anchors: (0..hits.len()).map(NodeRowId::from_usize).collect(), comparisons: Box::new([]), - neighbourhoods: IdSlice::from_boxed_slice(Box::new([NonZero::new(1).expect("nonzero")])), + neighbourhoods: IdSlice::from_boxed_slice(Box::new([nz!(1)])), map_representation: ReadingGrid::from_anchor_cells(cells.clone(), 1), clumps: None, sampled_map_representation: ReadingGrid::from_anchor_cells(cells.clone(), 1), @@ -999,7 +996,7 @@ fn clump_readings_of(matches: &[bool]) -> ClumpReadings { let cells: Vec> = matches .iter() .map(|&matched| { - let mut aggregate = ClumpAggregate::new(NonZero::new(1).expect("nonzero")); + let mut aggregate = ClumpAggregate::new(nz!(1)); aggregate.observe(&mut [0], &mut [u32::from(!matched)]); vec![aggregate] }) @@ -1227,8 +1224,8 @@ fn threshold_overrides_validate_at_the_boundary() { ), ]; for (document, field) in refusals { - let overrides: ThresholdOverrides = - serde_json::from_str(document).expect("the shape parses; the domain refuses"); + let overrides: ThresholdOverrides = serde_json::from_str(document) + .expect("should parse the JSON shape before domain validation rejects its value"); let error = QualityThresholds::default() .with_overrides(&overrides) .expect_err("an out-of-domain override refuses"); @@ -1606,9 +1603,14 @@ fn runner_classifier() -> ClassifierInput { .collect(); let training = TrainingSet::new(embeddings, &rows).expect("the fixture corpus validates"); - let classifier = fit_classifier(training, ClassifierFitConfig { folds: 2, .. }, &NoProgress) - .expect("the fixture classifier fits") - .classifier; + let classifier = fit_classifier( + training, + ClassifierFitConfig::new(ClassifierFitOptions { folds: 2, .. }) + .expect("the fixture classifier fit config is valid"), + &NoProgress, + ) + .expect("the fixture classifier fits") + .classifier; let mut hasher = Sha256::new(); hasher.update(b"fixture classifier artifact"); @@ -1622,12 +1624,9 @@ fn runner_classifier() -> ClassifierInput { fn runner_probe_options() -> QualityRunOptions { QualityRunOptions { probe: ProbeOptions { - anchors: NonZero::new(8).expect("nonzero"), - comparisons: NonZero::new(16).expect("nonzero"), - neighbourhoods: Cow::Owned(vec![ - NonZero::new(2).expect("nonzero"), - NonZero::new(4).expect("nonzero"), - ]), + anchors: nz!(8), + comparisons: nz!(16), + neighbourhoods: Cow::Owned(vec![nz!(2), nz!(4)]), triplet_pairs: 8, .. }, @@ -1644,12 +1643,12 @@ async fn runner_reports_a_published_generation() { let config = FitConfig { seed: 7, selection: SelectionOptions { - maximum_count: NonZero::new(8).expect("the fixture capacity is nonzero"), + maximum_count: nz!(8), .. }, curve: AffinityCurve::fit(positive!(1.0), positive!(0.1)) .expect("the reference falloff is well-conditioned"), - neighbours: NonZero::new(4).expect("the fixture neighbour count is nonzero"), + neighbours: nz!(4), // The quality fixture probes the metric suite, not the // placement: it opts out of the default's training run. placement: PlacementOptions::LandmarkBaseline, diff --git a/libs/@local/graph/atlas/src/salt/relation/artifact.rs b/libs/@local/graph/atlas/src/salt/relation/artifact.rs index adaf73e10bd..97a5664393a 100644 --- a/libs/@local/graph/atlas/src/salt/relation/artifact.rs +++ b/libs/@local/graph/atlas/src/salt/relation/artifact.rs @@ -13,8 +13,7 @@ not(test), expect( dead_code, - reason = "hard-negative mining is the designed reader of the mapped evidence, not yet \ - implemented" + reason = "some mapped index inspection APIs are used only by tests" ) )] diff --git a/libs/@local/graph/atlas/src/salt/relation/attraction.rs b/libs/@local/graph/atlas/src/salt/relation/attraction.rs index 32785d59f7d..f24df5e2d98 100644 --- a/libs/@local/graph/atlas/src/salt/relation/attraction.rs +++ b/libs/@local/graph/atlas/src/salt/relation/attraction.rs @@ -17,11 +17,12 @@ use crate::{ /// by default. A nonzero coefficient is accepted without checking any release criterion. The /// calibration starting grid is `2..=8`, to be judged against the generation's quality evidence. /// -/// The pruning threshold `η_F` drops instances whose force mass `c · s · s+` cannot move the -/// layout, and 0 retains every instance. The omitted-mass fraction a threshold produces -/// ([`super::BuildMeasurements::omitted_mass_fraction`]) audits it, and the threshold controls only -/// attraction sampling. Protection masses never pass through it. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +/// The pruning threshold `η_F` drops instances whose mass `c · s · s+` is strictly below it. It is +/// zero by default, retaining every non-self instance, including zero-mass ones. Evaluate a chosen +/// threshold through [`super::BuildMeasurements::omitted_mass_fraction`] and quality measurements. +/// The mass excludes degree normalization, strength and class-energy derivatives, and is not a +/// movement bound. Protection masses never pass through this predicate. +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct AttractionOptions { coincident_coefficient: NonNegative = NonNegative::ZERO, pruning_threshold: NonNegative = NonNegative::ZERO, diff --git a/libs/@local/graph/atlas/src/salt/relation/bench/fixture.rs b/libs/@local/graph/atlas/src/salt/relation/bench/fixture.rs index 0737c007d1d..3b927033b0b 100644 --- a/libs/@local/graph/atlas/src/salt/relation/bench/fixture.rs +++ b/libs/@local/graph/atlas/src/salt/relation/bench/fixture.rs @@ -147,8 +147,8 @@ impl Corpus { clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss, - reason = "the hub count is far below f64 integer precision, and the Zipf power \ - lies in [1, hubs), so the floor fits every integer type in play" + reason = "the power-of-two hub count converts exactly to f64. The power lies \ + between one and that count, within the integer encodings" )] let rank = (hubs as f64).powf(rng.random::()) as u64 - 1; let target = N::from_u64(rank.wrapping_mul(HUB_SCATTER) & (rows as u64 - 1)); diff --git a/libs/@local/graph/atlas/src/salt/relation/bench/tests.rs b/libs/@local/graph/atlas/src/salt/relation/bench/tests.rs index e78d981ed8b..241fe1489bf 100644 --- a/libs/@local/graph/atlas/src/salt/relation/bench/tests.rs +++ b/libs/@local/graph/atlas/src/salt/relation/bench/tests.rs @@ -8,7 +8,10 @@ use hashql_core::id::Id as _; use rand_xoshiro::Xoshiro256PlusPlus; use super::{Corpus, Profile}; -use crate::identity::{EdgeRowId, NodeRowId}; +use crate::{ + identity::{EdgeRowId, NodeRowId}, + math::nz, +}; /// Link count of the synthesised bench corpora. const LINKS: usize = 4_096; @@ -150,7 +153,7 @@ fn summary_self_references(corpus: &Corpus) -> usize { #[test] fn judge_layouts_agree() { let live = corpus(Profile::Live); - let per_row = core::num::NonZero::new(24).expect("the candidate width is positive"); + let per_row = nz!(24); // include both hit-poor and hit-rich probe sets in the access-layout comparison. for fraction in [0.0, 0.25, 0.9] { @@ -166,7 +169,7 @@ fn judge_layouts_agree() { #[test] fn judge_hit_rate_follows_partner_fraction() { let live = corpus(Profile::Live); - let per_row = core::num::NonZero::new(24).expect("the candidate width is positive"); + let per_row = nz!(24); // partner draws increase the expected hit rate under zero thresholds. let uniform = live.judge_probes::(per_row, 0.0, SEED); diff --git a/libs/@local/graph/atlas/src/salt/relation/build.rs b/libs/@local/graph/atlas/src/salt/relation/build.rs index cca84b56260..bd916f749c4 100644 --- a/libs/@local/graph/atlas/src/salt/relation/build.rs +++ b/libs/@local/graph/atlas/src/salt/relation/build.rs @@ -16,7 +16,7 @@ use super::{ error::RelationIndexError, protection::{NodePair, PairEvidence, ProtectionIndex, ProtectionMatrix}, }; -use crate::math::{DNonNegative, NonNegative, PositiveUnitFraction, narrow_f32}; +use crate::math::{DNonNegative, NonNegative, PositiveUnitFraction, UnitFraction, narrow_f32}; /// Instances per parallel emission chunk within one relation group. /// @@ -165,8 +165,8 @@ pub(super) fn resolve_groups<'policy, N, E>( #[derive(Debug, Copy, Clone)] pub(super) struct ProtectionRecord { pair: NodePair, - discounted: f32, - undiscounted: f32, + discounted: NonNegative, + undiscounted: NonNegative, } impl ProtectionRecord { @@ -181,8 +181,8 @@ impl ProtectionRecord { { Self { pair: NodePair::new(N::from_u64(0), N::from_u64(0)), - discounted: 0.0, - undiscounted: 0.0, + discounted: NonNegative::ZERO, + undiscounted: NonNegative::ZERO, } } } @@ -307,8 +307,8 @@ where /// Aggregates one canonical pair's contiguous records into evidence. fn pair_evidence(run: &[ProtectionRecord]) -> PairEvidence { - let mut discounted = 0.0_f32; - let mut undiscounted = 0.0_f32; + let mut discounted = NonNegative::ZERO; + let mut undiscounted = NonNegative::ZERO; for record in run { discounted = discounted.max(record.discounted); undiscounted = undiscounted.max(record.undiscounted); @@ -326,10 +326,11 @@ struct GroupFactors { /// The positive force scale `s+`. scale: NonNegative, /// The selected positive class evidence `p_C + p_P`, in double precision. - positive: f64, - /// The relation's calibrated applicability `a`, narrowed once so the protection evidence - /// derives from one shared `f32` reading. - applicability: f32, + positive: DNonNegative, + /// The relation's calibrated applicability `a`, narrowed once to `f32`. + /// + /// Every protection record in the group uses this same rounded value. + applicability: UnitFraction, } /// Builds one relation's attraction group from its contiguous instances. @@ -366,8 +367,8 @@ where }; let factors = GroupFactors { scale: weights.scale(), - positive: f64::from(policy.selected.coincident) + f64::from(policy.selected.proximal), - applicability: narrow_f32(policy.applicability.get()).expect("a fraction narrows finitely"), + positive: (policy.selected.coincident + policy.selected.proximal), + applicability: policy.applicability, }; let share = |instance: &RelationInstance| f64::from(instance.multiplicity.max(1)).recip(); @@ -480,18 +481,16 @@ where let confidence = instance.confidence.effective(); let confidence_value = confidence.value(); - // One narrow derives the undiscounted side, and the discounted side multiplies the - // narrowed value: `undiscounted · a ≤ undiscounted` exactly, because multiplying a - // non-negative f32 by a factor ∈ [0, 1] cannot round above it, so every record keeps - // `discounted ≤ undiscounted` and taking the maximum over records keeps that ordering. - // The shared intermediate is also what keeps the protection floor identity exact: both - // sides of `max(discounted, F · undiscounted)` scale the same f32 value. - let undiscounted = narrow_f32(confidence_value * factors.positive) - .expect("a fraction of a finite f32 factor narrows finitely"); + // Rounded multiplication of a finite non-negative f32 by a factor in [0, 1] cannot exceed + // the original value. The discounted component scales the already-narrowed undiscounted + // component by the narrowed applicability. Therefore each record satisfies discounted ≤ + // undiscounted, and taking independent maxima preserves that order. Sharing the narrowed + // value also preserves the floor factorization described in protection. + let undiscounted = confidence_value * factors.positive; *record = ProtectionRecord { pair: instance.pair(), - discounted: undiscounted * factors.applicability, - undiscounted, + discounted: (factors.applicability * undiscounted).narrow_lossy(), + undiscounted: undiscounted.narrow_lossy(), }; let share = f64::from(instance.multiplicity.max(1)).recip(); diff --git a/libs/@local/graph/atlas/src/salt/relation/mod.rs b/libs/@local/graph/atlas/src/salt/relation/mod.rs index 7e038ec7356..a2b36a86750 100644 --- a/libs/@local/graph/atlas/src/salt/relation/mod.rs +++ b/libs/@local/graph/atlas/src/salt/relation/mod.rs @@ -207,7 +207,7 @@ impl<'policy> From<&'policy CertifiedPolicies> for Policies<'policy> { /// The build's account of dropped instances and pruned force mass. /// /// The recorded threshold is the criterion the pruned/retained split was judged against. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct BuildMeasurements { /// The force-pruning threshold the build applied. pub pruning_threshold: NonNegative, diff --git a/libs/@local/graph/atlas/src/salt/relation/protection.rs b/libs/@local/graph/atlas/src/salt/relation/protection.rs index 9c403a5906c..eb4c29b6ddd 100644 --- a/libs/@local/graph/atlas/src/salt/relation/protection.rs +++ b/libs/@local/graph/atlas/src/salt/relation/protection.rs @@ -41,7 +41,7 @@ use sprs::{CsMatI, CsMatViewI}; use crate::{ file::sprs::{SprsValue, ValueTag}, - math::NonNegative, + math::{NonNegative, UnitFraction}, }; /// A sparse evidence matrix with `u32` partner columns and `u64` row pointers. @@ -65,7 +65,7 @@ pub(crate) type ProtectionMatrixView<'view> = CsMatViewI<'view, PairEvidence, u3 Clone, PartialEq, Default, - zerocopy::FromBytes, + zerocopy::TryFromBytes, zerocopy::IntoBytes, zerocopy::Immutable, zerocopy::KnownLayout, @@ -73,9 +73,9 @@ pub(crate) type ProtectionMatrixView<'view> = CsMatViewI<'view, PairEvidence, u3 #[repr(C)] pub(crate) struct PairEvidence { /// The applicability-discounted evidence maximum, `max(c · (p_C + p_P) · a)`. - pub discounted: f32, + pub discounted: NonNegative, /// The undiscounted evidence maximum, `max(c · (p_C + p_P))`. - pub undiscounted: f32, + pub undiscounted: NonNegative, } impl PairEvidence { @@ -85,8 +85,9 @@ impl PairEvidence { /// module's floor identity, `self` must contain valid aggregated evidence. #[inline] #[must_use] - pub(crate) fn mass(self, floor: f32) -> f32 { - self.discounted.max(floor * self.undiscounted) + pub(crate) const fn mass(self, floor: UnitFraction) -> NonNegative { + let undiscounted = self.undiscounted * floor; + self.discounted.max(undiscounted) } } @@ -97,52 +98,24 @@ impl SprsValue for PairEvidence { /// One protection channel's applicability floor and admission threshold, valid by construction. /// -/// The floor lifts a relation's calibrated applicability before it enters the channel's mass, so a -/// relation too unfamiliar to earn pull can still retain enough evidence to veto repulsion. A floor -/// of 0 leaves applicability undisturbed. The threshold is the mass at which the channel protects. -/// A threshold of 0 protects every linked pair, the conservative reading of link evidence. Floors -/// and thresholds jointly determine the protected set. Calibration fixes them together from -/// reviewed validation pairs. -#[derive(Debug, Copy, Clone, PartialEq)] +/// Flooring applicability preserves protection evidence for unfamiliar relations even when low +/// applicability reduces their attraction. A floor of zero leaves applicability undisturbed. The +/// threshold is the mass at which the channel protects. Both are zero by default, protecting every +/// stored pair, including zero-evidence pairs. Calibrate floors and thresholds together against +/// labeled validation pairs. +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct ChannelConfig { - floor: f32 = 0.0, - threshold: f32 = 0.0, + pub floor: UnitFraction = UnitFraction::ZERO, + pub threshold: NonNegative = NonNegative::ZERO, } impl ChannelConfig { /// Returns whether the floored mass reaches the channel's threshold. /// - /// Returns [`None`] unless the floor lies in `0.0..=1.0` and the threshold is finite and - /// non-negative. The default is floor 0, threshold 0. - #[must_use] - pub(crate) const fn new(floor: f32, threshold: f32) -> Option { - if !(floor >= 0.0 && floor <= 1.0) { - return None; - } - if !(threshold.is_finite() && threshold >= 0.0) { - return None; - } - Some(Self { floor, threshold }) - } - - /// Returns the applicability floor. - #[inline] - #[must_use] - pub(crate) const fn floor(self) -> f32 { - self.floor - } - - /// Returns the admission threshold. - #[inline] - #[must_use] - pub(crate) const fn threshold(self) -> f32 { - self.threshold - } - - /// Returns whether `evidence` clears the channel. + /// `evidence` must satisfy [`ProtectionIndex`]'s value invariants. #[inline] #[must_use] - pub(crate) fn protects(self, evidence: PairEvidence) -> bool { + pub(crate) const fn protects(self, evidence: PairEvidence) -> bool { evidence.mass(self.floor) >= self.threshold } } @@ -153,12 +126,49 @@ const impl Default for ChannelConfig { } } +/// Protection channels whose floors or thresholds violate their shared ordering. +#[derive(Debug)] +struct UnvalidatedProtectionConfigError { + _marker: PhantomData<()>, +} + +impl fmt::Display for UnvalidatedProtectionConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("the ordinary channel must be less conservative than the hard channel") + } +} + +/// Protection settings awaiting the cross-channel ordering check. +#[derive(Debug, serde::Deserialize)] +struct UnvalidatedProtectionConfig { + hard: ChannelConfig, + ordinary: ChannelConfig, + protect_ordinary: bool, +} + +impl TryFrom for ProtectionConfig { + type Error = UnvalidatedProtectionConfigError; + + fn try_from( + UnvalidatedProtectionConfig { + hard, + ordinary, + protect_ordinary, + }: UnvalidatedProtectionConfig, + ) -> Result { + Self::new(hard, ordinary, protect_ordinary).ok_or(UnvalidatedProtectionConfigError { + _marker: PhantomData, + }) + } +} + /// Both channels' query-time protection settings, valid by construction. /// /// The channels satisfy `ordinary.floor ≤ hard.floor` and `hard.threshold ≤ ordinary.threshold`: -/// hard negatives are aimed at specific pairs, so their channel warrants at least as much caution -/// in the floor and no more evidence to trip in the threshold. -#[derive(Debug, Copy, Clone, PartialEq)] +/// the hard channel is at least as conservative as the ordinary channel. Both channels use floor +/// zero and threshold zero by default, with ordinary protection enabled. +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "UnvalidatedProtectionConfig")] pub(crate) struct ProtectionConfig { hard: ChannelConfig = ChannelConfig::default(), ordinary: ChannelConfig = ChannelConfig::default(), @@ -303,8 +313,7 @@ pub(crate) enum ProtectionValidationError { NotSquare { rows: usize, columns: usize }, /// A row references itself. SelfEdge { row: usize }, - /// A stored evidence component is not finite or negative. - EvidenceOutOfRange { row: usize, column: usize }, + /// A stored evidence pair has `discounted > undiscounted`. EvidenceOrdering { row: usize, column: usize }, /// The matrix stores an edge in one direction only. @@ -324,11 +333,6 @@ impl fmt::Display for ProtectionValidationError { "the protection matrix spans {rows} rows by {columns} columns", ), Self::SelfEdge { row } => write!(fmt, "row {row} references itself"), - Self::EvidenceOutOfRange { row, column } => write!( - fmt, - "the evidence between rows {row} and {column} has a non-finite or negative \ - component", - ), Self::EvidenceOrdering { row, column } => write!( fmt, "the evidence between rows {row} and {column} discounts above its undiscounted \ @@ -402,13 +406,6 @@ fn validate_row( return Err(ProtectionValidationError::SelfEdge { row }); } - let in_range = NonNegative::new(evidence.discounted).is_some() - && NonNegative::new(evidence.undiscounted).is_some(); - - if !in_range { - return Err(ProtectionValidationError::EvidenceOutOfRange { row, column }); - } - if evidence.discounted > evidence.undiscounted { return Err(ProtectionValidationError::EvidenceOrdering { row, column }); } @@ -502,7 +499,7 @@ where /// Returns the stored entry count, counting each pair twice. #[inline] #[must_use] - #[cfg(test)] // The relation tests count stored pairs. + #[cfg(test)] pub(crate) fn entries(&self) -> usize { self.0.nnz() } diff --git a/libs/@local/graph/atlas/src/salt/relation/tests.rs b/libs/@local/graph/atlas/src/salt/relation/tests.rs index fef32132d30..aba5f50e343 100644 --- a/libs/@local/graph/atlas/src/salt/relation/tests.rs +++ b/libs/@local/graph/atlas/src/salt/relation/tests.rs @@ -1,7 +1,7 @@ #![expect( clippy::float_cmp, - reason = "weight factors are hand-picked exactly representable values (powers of two), so the \ - asserted products and square roots are exact contracts" + reason = "fixtures pin representable arithmetic, copied factors, and equal results from the \ + same ordered floating-point operations" )] use core::assert_matches; @@ -118,8 +118,15 @@ fn pair(one: u64, other: u64) -> NodePair { /// Panics for out-of-domain settings or incorrectly ordered channels. fn config(hard: (f32, f32), ordinary: (f32, f32)) -> ProtectionConfig { ProtectionConfig::new( - ChannelConfig::new(hard.0, hard.1).expect("the fixture channel is in domain"), - ChannelConfig::new(ordinary.0, ordinary.1).expect("the fixture channel is in domain"), + ChannelConfig { + floor: UnitFraction::new(f64::from(hard.0)).expect("hard.0 is inside of [0, 1]"), + threshold: NonNegative::new(hard.1).expect("hard.1 is inside of [0, inf)"), + }, + ChannelConfig { + floor: UnitFraction::new(f64::from(ordinary.0)) + .expect("ordinary.0 is inside of [0, 1]"), + threshold: NonNegative::new(ordinary.1).expect("ordinary.1 is inside of [0, inf)"), + }, true, ) .expect("the fixture channels are ordered") @@ -352,9 +359,9 @@ fn evidence_components_aggregate_independently_by_maximum() { .expect("the linked pair is present"); assert_eq!(evidence.discounted, 0.375); assert_eq!(evidence.undiscounted, 1.0); - assert_eq!(evidence.mass(0.0), 0.375); - assert_eq!(evidence.mass(0.5), 0.5); - assert_eq!(evidence.mass(1.0), 1.0); + assert_eq!(evidence.mass(unit_fraction!(0.0)), 0.375); + assert_eq!(evidence.mass(unit_fraction!(0.5)), 0.5); + assert_eq!(evidence.mass(unit_fraction!(1.0)), 1.0); } #[test] @@ -528,12 +535,14 @@ fn row_domains_beyond_the_column_encoding_are_rejected() { #[test] fn option_constructors_reject_out_of_domain_settings() { - assert!(ChannelConfig::new(1.5, 0.0).is_none()); - assert!(ChannelConfig::new(f32::NAN, 0.0).is_none()); - assert!(ChannelConfig::new(0.0, -1.0).is_none()); - assert!(ChannelConfig::new(0.0, f32::NAN).is_none()); - let low = ChannelConfig::new(0.25, 0.5).expect("the channel is in domain"); - let high = ChannelConfig::new(0.5, 0.25).expect("the channel is in domain"); + let low = ChannelConfig { + floor: unit_fraction!(0.25), + threshold: non_negative!(0.5), + }; + let high = ChannelConfig { + floor: unit_fraction!(0.5), + threshold: non_negative!(0.25), + }; // Hard wants the higher floor and the lower threshold. assert!(ProtectionConfig::new(low, high, true).is_none()); @@ -731,10 +740,15 @@ fn build_is_order_independent_and_sorted( } prop_assert_eq!(mirrored, view.entries()); - // The floor identity is exact: the stored two-component evidence reproduces every floored - // per-instance mass. The reference applies the floor inside the per-instance maximum, the form - // the identity factorizes. - for floor in [0.0_f32, 0.25, 0.5, 1.0] { + // the reference floors applicability before multiplying each instance's shared f32 evidence. + // This compares the unfactorized expression with the stored maxima at the same rounding + // boundaries. + for floor in [ + unit_fraction!(0.0), + unit_fraction!(0.25), + unit_fraction!(0.5), + unit_fraction!(1.0), + ] { for row in 0..ROWS as u64 { for entry in view.row(NodeRowId::new(row)) { let expected = forward_reference_mass( @@ -1010,8 +1024,10 @@ fn forward_reference_mass( instances: &[RelationInstance], policies: &[RelationPolicy], pair: NodePair, - floor: f32, + floor: UnitFraction, ) -> f32 { + let floor = floor.as_f32(); + let mut mass = 0.0_f32; for instance in instances { if NodePair::new(instance.source, instance.target) != pair diff --git a/libs/@local/graph/atlas/src/salt/runner/mod.rs b/libs/@local/graph/atlas/src/salt/runner/mod.rs index 928f3452374..072478bd15b 100644 --- a/libs/@local/graph/atlas/src/salt/runner/mod.rs +++ b/libs/@local/graph/atlas/src/salt/runner/mod.rs @@ -10,7 +10,12 @@ //! including while a run fits or probes. The runner's quality check supplies no restriction on //! direct [`GenerationRoot::activate`] calls. //! -//! Retiring old generations is offline tooling over published directories. +//! The admission probe's generator derives from the fit seed under a fixed label. Equal seeds, +//! population row order and sampling settings reproduce its anchor sample with the same sampler +//! implementation. Replaying a complete fit additionally depends on the dataset, supplied +//! artifacts, prior generation, fit configuration and numerical environment. + +use core::panic::UnwindSafe; use rand::SeedableRng as _; use rand_xoshiro::Xoshiro256PlusPlus; @@ -117,7 +122,7 @@ pub(crate) async fn run( where D: Dataset, E: CardEmbedder + Sync, - P: Progress + Sync, + P: Progress + Sync, { let prior = match options.prior { PriorMode::FromActive => root diff --git a/libs/@local/graph/atlas/src/salt/runner/operator/live.rs b/libs/@local/graph/atlas/src/salt/runner/operator/live.rs index 7b0c8b21553..56a37ea084c 100644 --- a/libs/@local/graph/atlas/src/salt/runner/operator/live.rs +++ b/libs/@local/graph/atlas/src/salt/runner/operator/live.rs @@ -1,4 +1,4 @@ -//! Runs one production generation over a pinned store snapshot. +use core::panic::UnwindSafe; use hash_graph_embeddings::OpenAiEmbeddingClient; use tokio_postgres::Client; @@ -20,20 +20,20 @@ use crate::{ /// /// # Errors /// -/// Returns a [`RunError`] naming the step that failed: opening the snapshot transaction, admitting -/// the supplied verdicts, quality-thresholds, annotation-corpus, or classifier documents, or the -/// run itself. -pub(crate) async fn live( +/// Returns [`RunError`] when snapshot creation, supplied-document resolution or the generation run +/// fails. The snapshot opens before document resolution. +pub(crate) async fn live

( client: &mut Client, root: GenerationRoot, device: PinnedDevice, axes: TemporalAxes, options: Options

, embedder: &ExternalEmbeddingProvider, -) -> Result { - let dataset = PostgresDataset::new(client, axes) - .await - .map_err(RunError::Snapshot)?; +) -> Result +where + P: Progress + Sync, +{ + let dataset = PostgresDataset::new(client, axes).await?; let resolved = resolve(&options, device)?; @@ -46,8 +46,7 @@ pub(crate) async fn live( resolved.runner, &options.progress, ) - .await - .map_err(RunError::Run)?; + .await?; Ok(summary(&outcome)) } diff --git a/libs/@local/graph/atlas/src/salt/runner/operator/mod.rs b/libs/@local/graph/atlas/src/salt/runner/operator/mod.rs index 1def84613a7..283cabfbd8e 100644 --- a/libs/@local/graph/atlas/src/salt/runner/operator/mod.rs +++ b/libs/@local/graph/atlas/src/salt/runner/operator/mod.rs @@ -191,21 +191,8 @@ impl core::error::Error for ThresholdSupplyError { } } -/// One production run's failure, by step. -/// -/// Every variant names the step that failed and holds that step's concrete fault - nothing erases -/// to `dyn`. -/// -/// The run payload's concrete type stays inside the crate. An external caller reads it -/// through [`Error::source`](core::error::Error::source) as `&dyn Error`, and only in-crate -/// consumers match on it. -#[expect( - private_interfaces, - reason = "the run variant's payload is reachable outside the crate as a `dyn Error` source \ - alone, and naming its concrete type stays an in-crate capability" -)] #[derive(Debug)] -pub enum RunError { +enum RunErrorKind { /// The store could not open a snapshot transaction. Snapshot(PostgresDatasetError), /// The dump directory was refused. @@ -226,23 +213,38 @@ pub enum RunError { OfflineRun(RunnerError), } +/// A step-specific failure from a live or offline generation run. +/// +/// Every variant retains the step's concrete error. Use [`core::error::Error::source`] to inspect +/// the underlying failure, including runner errors whose concrete type is crate-private. +#[derive(Debug)] +pub struct RunError { + kind: RunErrorKind, +} + impl core::fmt::Display for RunError { fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::Snapshot(_) => fmt.write_str("the store could not open a snapshot transaction"), - Self::Dump(_) => fmt.write_str("the dump directory was refused"), - Self::DumpEmbedder(_) => { + match &self.kind { + RunErrorKind::Snapshot(_) => { + fmt.write_str("the store could not open a snapshot transaction") + } + RunErrorKind::Dump(_) => fmt.write_str("the dump directory was refused"), + RunErrorKind::DumpEmbedder(_) => { fmt.write_str("the dump's embedding stream was refused as the embedding provider") } - Self::Verdicts(_) => fmt.write_str("the supplied verdicts document was refused"), - Self::Thresholds(_) => { + RunErrorKind::Verdicts(_) => { + fmt.write_str("the supplied verdicts document was refused") + } + RunErrorKind::Thresholds(_) => { fmt.write_str("the supplied quality-thresholds document was refused") } - Self::Annotations(_) => { + RunErrorKind::Annotations(_) => { fmt.write_str("the supplied annotation-corpus document was refused") } - Self::Classifier(_) => fmt.write_str("the supplied classifier artifact was refused"), - Self::Run(_) | Self::OfflineRun(_) => { + RunErrorKind::Classifier(_) => { + fmt.write_str("the supplied classifier artifact was refused") + } + RunErrorKind::Run(_) | RunErrorKind::OfflineRun(_) => { fmt.write_str("the run could not reach a verdict") } } @@ -251,16 +253,88 @@ impl core::fmt::Display for RunError { impl core::error::Error for RunError { fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { - match self { - Self::Snapshot(error) => Some(error), - Self::Dump(error) => Some(error), - Self::DumpEmbedder(error) => Some(error), - Self::Verdicts(error) => Some(error), - Self::Thresholds(error) => Some(error), - Self::Annotations(error) => Some(error), - Self::Classifier(error) => Some(error), - Self::Run(error) => Some(error), - Self::OfflineRun(error) => Some(error), + match &self.kind { + RunErrorKind::Snapshot(error) => Some(error), + RunErrorKind::Dump(error) => Some(error), + RunErrorKind::DumpEmbedder(error) => Some(error), + RunErrorKind::Verdicts(error) => Some(error), + RunErrorKind::Thresholds(error) => Some(error), + RunErrorKind::Annotations(error) => Some(error), + RunErrorKind::Classifier(error) => Some(error), + RunErrorKind::Run(error) => Some(error), + RunErrorKind::OfflineRun(error) => Some(error), + } + } +} + +const impl From for RunError { + fn from(error: PostgresDatasetError) -> Self { + Self { + kind: RunErrorKind::Snapshot(error), + } + } +} + +const impl From for RunError { + fn from(error: OpenDumpError) -> Self { + Self { + kind: RunErrorKind::Dump(error), + } + } +} + +const impl From for RunError { + fn from(error: OfflineDatasetError) -> Self { + Self { + kind: RunErrorKind::DumpEmbedder(error), + } + } +} + +const impl From for RunError { + fn from(error: VerdictSupplyError) -> Self { + Self { + kind: RunErrorKind::Verdicts(error), + } + } +} + +const impl From for RunError { + fn from(error: ThresholdSupplyError) -> Self { + Self { + kind: RunErrorKind::Thresholds(error), + } + } +} + +const impl From for RunError { + fn from(error: AnnotationSupplyError) -> Self { + Self { + kind: RunErrorKind::Annotations(error), + } + } +} + +const impl From for RunError { + fn from(error: ClassifierSupplyError) -> Self { + Self { + kind: RunErrorKind::Classifier(error), + } + } +} + +const impl From> for RunError { + fn from(error: RunnerError) -> Self { + Self { + kind: RunErrorKind::Run(error), + } + } +} + +const impl From> for RunError { + fn from(error: RunnerError) -> Self { + Self { + kind: RunErrorKind::OfflineRun(error), } } } @@ -274,10 +348,10 @@ impl core::error::Error for RunError { fn classifier_input(source: &ClassifierSource) -> Result { match source { ClassifierSource::Annotations(path) => Ok(ClassifierInput::Annotations( - SuppliedAnnotations::open(path).map_err(RunError::Annotations)?, + SuppliedAnnotations::open(path)?, )), ClassifierSource::Artifact(path) => { - ClassifierInput::open_artifact(path).map_err(RunError::Classifier) + ClassifierInput::open_artifact(path).map_err(From::from) } } } @@ -315,12 +389,12 @@ fn placement_options(placement: Placement, initial: PlacementOptions) -> Placeme let mut projector = match (steps, initial) { (Some(steps), _) => { - let mut projector = ProjectorOptions::ratified(); + let mut projector = ProjectorOptions::live(); projector.schedule = TrainingSchedule::shortened(steps); projector } (None, PlacementOptions::Projector(projector)) => projector, - (None, PlacementOptions::LandmarkBaseline) => ProjectorOptions::ratified(), + (None, PlacementOptions::LandmarkBaseline) => ProjectorOptions::live(), }; projector.vacuous = vacuous; @@ -373,8 +447,7 @@ fn resolve

(options: &Options

, device: PinnedDevice) -> Result(options: &Options

, device: PinnedDevice) -> Result( +/// Returns [`RunError`] when supplied-document resolution, dump opening, embedding indexing or the +/// generation run fails, in that order. +pub(crate) async fn offline

( dump: &Utf8Path, root: GenerationRoot, device: PinnedDevice, options: Options

, -) -> Result { +) -> Result +where + P: Progress + Sync, +{ let resolved = resolve(&options, device)?; - let dataset = OfflineDataset::open(dump).map_err(RunError::Dump)?; - let embedder = dataset.embedder().map_err(RunError::DumpEmbedder)?; + let dataset = OfflineDataset::open(dump)?; + let embedder = dataset.embedder()?; let outcome = run( &dataset, @@ -43,8 +45,7 @@ pub(crate) async fn offline( resolved.runner, &options.progress, ) - .await - .map_err(RunError::OfflineRun)?; + .await?; Ok(summary(&outcome)) } diff --git a/libs/@local/graph/atlas/src/salt/runner/tests.rs b/libs/@local/graph/atlas/src/salt/runner/tests.rs index 3f96c58de24..41d3a2e3363 100644 --- a/libs/@local/graph/atlas/src/salt/runner/tests.rs +++ b/libs/@local/graph/atlas/src/salt/runner/tests.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, sync::Arc}; -use core::{future::ready, num::NonZero}; +use core::future::ready; use std::{collections::HashMap, sync::Mutex}; use camino::Utf8PathBuf; @@ -19,14 +19,15 @@ use crate::{ file::generation::GenerationRoot, identity::{CardRow, NodeRowId, OntologyRowId}, integrity::{Sha256, Update as _}, - math::{AffinityCurve, AlignedVecN, BoxedVecN, UnitFraction, VecN, positive}, + math::{AffinityCurve, AlignedVecN, BoxedVecN, UnitFraction, VecN, nz, positive}, progress::{NoProgress, Progress}, salt::{ embedding::{CardEmbedder, EmbedderFingerprint}, fit::{ClassifierInput, FitConfig, PlacementOptions}, landmark::select::SelectionOptions, policy::classifier::{ - FitConfig as ClassifierFitConfig, TrainingRow, TrainingSet, fit as fit_classifier, + FitConfig as ClassifierFitConfig, FitOptions as ClassifierFitOptions, TrainingRow, + TrainingSet, fit as fit_classifier, }, quality::{ QualityMetric, probe::ProbeOptions, report::QualityThresholds, @@ -211,9 +212,14 @@ fn classifier() -> ClassifierInput { .collect(); let training = TrainingSet::new(embeddings, &rows).expect("the fixture corpus validates"); - let classifier = fit_classifier(training, ClassifierFitConfig { folds: 2, .. }, &NoProgress) - .expect("the fixture classifier fits") - .classifier; + let classifier = fit_classifier( + training, + ClassifierFitConfig::new(ClassifierFitOptions { folds: 2, .. }) + .expect("the fixture classifier fit config is valid"), + &NoProgress, + ) + .expect("the fixture classifier fits") + .classifier; let mut hasher = Sha256::new(); hasher.update(b"fixture classifier artifact"); @@ -229,26 +235,25 @@ fn options(seed: u64, thresholds: QualityThresholds) -> RunnerOptions { fit: FitConfig { seed, selection: SelectionOptions { - maximum_count: NonZero::new(8).expect("the fixture capacity is nonzero"), + maximum_count: nz!(8), .. }, curve: AffinityCurve::fit(positive!(1.0), positive!(0.1)) .expect("the reference falloff is well-conditioned"), - neighbours: NonZero::new(4).expect("the fixture neighbour count is nonzero"), - // The runner fixtures probe the run protocol, not the - // placement: they opt out of the default's training run. + neighbours: nz!(4), + // the landmark baseline keeps these protocol fixtures independent of projector + // training. placement: PlacementOptions::LandmarkBaseline, .. }, quality: QualityRunOptions { probe: ProbeOptions { - anchors: NonZero::new(8).expect("nonzero"), - comparisons: NonZero::new(16).expect("nonzero"), - // Step 2 is all-degenerate on this 8-node landmark-baseline fixture (coincident map - // placements zero the radii), and the verdict fails closed on absent density - // evidence. The quality tests pin the fail-closed arm itself, while the runner - // fixtures probe the run protocol, so they read the step where evidence exists. - neighbourhoods: Cow::Owned(vec![NonZero::new(4).expect("nonzero")]), + anchors: nz!(8), + comparisons: nz!(16), + // the fixture caps landmarks at eight for 48 rows. Coincident placements can + // remove density-spread evidence at small neighbourhood sizes. Size 4 is the + // selected neighbourhood for the passing admission case. + neighbourhoods: Cow::Owned(vec![nz!(4)]), triplet_pairs: 8, .. }, diff --git a/libs/@local/graph/atlas/src/salt/semantic/artifact.rs b/libs/@local/graph/atlas/src/salt/semantic/artifact.rs index 1bdda2ca9c2..190c590c5f3 100644 --- a/libs/@local/graph/atlas/src/salt/semantic/artifact.rs +++ b/libs/@local/graph/atlas/src/salt/semantic/artifact.rs @@ -8,8 +8,7 @@ not(test), expect( dead_code, - reason = "training and release evaluation are the designed readers of the mapped graph, \ - not yet implemented" + reason = "retained API for reading the published semantic graph" ) )] diff --git a/libs/@local/graph/atlas/src/salt/semantic/bandwidth.rs b/libs/@local/graph/atlas/src/salt/semantic/bandwidth.rs index 82998dfbf4c..b6ec2f36690 100644 --- a/libs/@local/graph/atlas/src/salt/semantic/bandwidth.rs +++ b/libs/@local/graph/atlas/src/salt/semantic/bandwidth.rs @@ -172,7 +172,7 @@ impl RowSolver { /// `values` must be nonempty for a defined mean. Both the sum and the length conversion can round. #[expect( clippy::cast_precision_loss, - reason = "neighbour counts stay far below exact f32 integer precision" + reason = "the row scale uses f32 arithmetic, including the rounded neighbour count" )] fn mean(values: &[f32]) -> f32 { values.iter().sum::() / values.len() as f32 diff --git a/libs/@local/graph/atlas/src/salt/semantic/mod.rs b/libs/@local/graph/atlas/src/salt/semantic/mod.rs index c112c97b140..e5fda2a0987 100644 --- a/libs/@local/graph/atlas/src/salt/semantic/mod.rs +++ b/libs/@local/graph/atlas/src/salt/semantic/mod.rs @@ -49,8 +49,11 @@ pub(crate) type SemanticMatrixView<'view> = CsMatViewI<'view, f32, u32, u64>; /// Smooth-kNN convergence limits and the distance-scaled bandwidth floor. /// -/// The defaults are the established UMAP fuzzy-set kernel constants. -#[derive(Debug, Copy, Clone, PartialEq)] +/// Calibration targets the membership-sum equation in [`bandwidth`]. The stopping tolerance applies +/// before the bandwidth and stored-membership floors, which can raise the final sum. For validated +/// k-NN distances in `[0, 2]`, the defaults keep both trial and returned bandwidths finite and +/// positive. Custom settings must preserve that condition to implement the exponential model. +#[derive(Debug, Copy, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub(crate) struct SmoothingOptions { /// Absolute membership-sum residual below which bisection stops early. /// @@ -79,8 +82,7 @@ const impl Default for SmoothingOptions { /// Returns [`SemanticValidationError`] when the matrix violates a graph invariant. #[expect( clippy::float_cmp, - reason = "the union weight is computed from commutative operations, so the two directions of \ - an edge are bit-equal by construction and validated exactly" + reason = "the graph requires exact equality between finite positive weights in both directions" )] fn validate(matrix: SemanticMatrixView<'_>) -> Result<(), SemanticValidationError> { if !matrix.is_csr() { @@ -185,8 +187,8 @@ where #[expect( clippy::cast_precision_loss, clippy::cast_possible_truncation, - reason = "neighbour and entry counts stay far below exact f64 integer precision, and the \ - corpus mean is a bandwidth floor scale whose low f32 bits are irrelevant" + reason = "counts become f64 for calibration arithmetic, and the corpus mean rounds to the \ + f32 bandwidth scale" )] pub(crate) fn build(knn: &KnnView<'_, N>, options: SmoothingOptions) -> Self { let rows = knn.rows(); diff --git a/libs/@local/graph/atlas/src/salt/semantic/tests.rs b/libs/@local/graph/atlas/src/salt/semantic/tests.rs index 38d2e511c3d..9d790086e32 100644 --- a/libs/@local/graph/atlas/src/salt/semantic/tests.rs +++ b/libs/@local/graph/atlas/src/salt/semantic/tests.rs @@ -1,7 +1,7 @@ #![expect( clippy::float_cmp, - reason = "symmetry is bit-exact by construction and saturated memberships are exactly one; \ - both are contracts, not coincidences" + reason = "the fixtures check exact symmetry, saturated memberships and same-kernel SIMD \ + results" )] use core::{assert_matches, simd::f32x8}; diff --git a/libs/@local/hashql/core/src/id/slice.rs b/libs/@local/hashql/core/src/id/slice.rs index 8d27a9aff1e..d2354aedc77 100644 --- a/libs/@local/hashql/core/src/id/slice.rs +++ b/libs/@local/hashql/core/src/id/slice.rs @@ -107,18 +107,14 @@ where /// Returns the underlying raw slice. #[inline] - #[expect(unsafe_code, reason = "repr(transparent)")] pub const fn as_raw(&self) -> &[T] { - // SAFETY: `IdSlice` is repr(transparent) and has the same layout as `[T]`. - unsafe { &*(ptr::from_ref(self) as *const [T]) } + &self.raw } /// Returns the underlying raw mutable slice. #[inline] - #[expect(unsafe_code, reason = "repr(transparent)")] pub const fn as_raw_mut(&mut self) -> &mut [T] { - // SAFETY: `IdSlice` is repr(transparent) and has the same layout as `[T]`. - unsafe { &mut *(ptr::from_mut(self) as *mut [T]) } + &mut self.raw } /// Converts a boxed slice into a boxed typed slice. @@ -1058,11 +1054,16 @@ where #[cfg(test)] mod tests { #![expect(unsafe_code, clippy::cast_possible_truncation)] - use alloc::boxed::Box; - use core::{mem::MaybeUninit, num::NonZero}; + use alloc::{boxed::Box, rc::Rc}; + use core::{ + clone::CloneToUninit as _, + mem::MaybeUninit, + num::NonZero, + sync::atomic::{AtomicUsize, Ordering}, + }; use super::IdSlice; - use crate::id::Id as _; + use crate::id::{Id as _, IdVec}; hashql_macros::define_id! { #[id(crate = crate)] @@ -1074,6 +1075,18 @@ mod tests { struct FourElementId(u8 is 0..=3) } + #[test] + fn raw_views_const() { + const VALUES: [u32; 3] = { + let mut values = [10, 20, 30]; + let slice = IdSlice::::from_raw_mut(&mut values); + slice.as_raw_mut()[1] = 42; + [slice.as_raw()[0], slice.as_raw()[1], slice.as_raw()[2]] + }; + + assert_eq!(VALUES, [10, 42, 30]); + } + #[test] fn from_raw_indexing() { let data = [10, 20, 30]; @@ -1259,4 +1272,130 @@ mod tests { assert!(init.is_empty()); } + + #[test] + fn clone_to_uninit_order() { + let data = [10_u32, 20, 30]; + let source = IdSlice::::from_raw(&data); + let mut buffer: Box<[MaybeUninit]> = Box::new_uninit_slice(3); + + // SAFETY: `buffer` holds exactly `source.len()` slots of `u32` with `u32`'s alignment, and + // `as_mut_ptr` points at its first byte. + unsafe { + source.clone_to_uninit(buffer.as_mut_ptr().cast::()); + } + + let boxed = IdSlice::::from_boxed_slice(buffer); + // SAFETY: `clone_to_uninit` returned normally, which initializes every slot. + let cloned = unsafe { IdSlice::boxed_assume_init(boxed) }; + + assert_eq!(cloned.as_raw(), &[10, 20, 30]); + } + + #[test] + fn boxed_clone_shared() { + let source: Box>> = + IdVec::from_raw(alloc::vec![Rc::new(1), Rc::new(2)]).into_boxed_slice(); + let first = TestId::from_usize(0); + let second = TestId::from_usize(1); + + let cloned = source.clone(); + + assert!(Rc::ptr_eq(&source[first], &cloned[first])); + assert!(Rc::ptr_eq(&source[second], &cloned[second])); + assert_eq!(Rc::strong_count(&source[first]), 2); + assert_eq!(Rc::strong_count(&source[second]), 2); + drop(cloned); + assert_eq!(Rc::strong_count(&source[first]), 1); + assert_eq!(Rc::strong_count(&source[second]), 1); + } + + #[test] + #[expect(clippy::redundant_clone, reason = "the test is testing exactly this")] + fn boxed_clone_empty() { + let source: Box>> = IdVec::new().into_boxed_slice(); + + let cloned = source.clone(); + + assert!(cloned.is_empty()); + } + + #[test] + #[expect(clippy::redundant_clone, reason = "the test is testing exactly this")] + fn boxed_clone_aligned_zst() { + static CLONES: AtomicUsize = AtomicUsize::new(0); + + #[repr(align(64))] + struct Unit; + + impl Clone for Unit { + fn clone(&self) -> Self { + CLONES.fetch_add(1, Ordering::Relaxed); + Self + } + } + + assert_eq!(core::mem::size_of::(), 0); + assert_eq!(core::mem::align_of::(), 64); + let source: Box> = + IdVec::from_raw(alloc::vec![Unit, Unit, Unit]).into_boxed_slice(); + + let cloned = source.clone(); + + assert_eq!(cloned.len(), 3); + assert_eq!(CLONES.load(Ordering::Relaxed), 3); + } + + #[test] + #[expect(clippy::redundant_clone, reason = "the test is testing exactly this")] + fn boxed_clone_aligned() { + #[repr(align(64))] + #[derive(Clone)] + struct Aligned(u8); + + let source: Box> = + IdVec::from_raw(alloc::vec![Aligned(1), Aligned(2)]).into_boxed_slice(); + + let cloned = source.clone(); + + assert_eq!(cloned.len(), 2); + assert_eq!(cloned[TestId::from_usize(0)].0, 1); + assert_eq!(cloned[TestId::from_usize(1)].0, 2); + assert!(cloned.as_raw().as_ptr().addr().is_multiple_of(64)); + } + + #[test] + fn boxed_clone_unwind() { + static DROPS: AtomicUsize = AtomicUsize::new(0); + + struct PanicsOnThird(u8); + + impl Clone for PanicsOnThird { + #[track_caller] + fn clone(&self) -> Self { + assert_ne!(self.0, 3, "third clone unwinds"); + Self(self.0) + } + } + + impl Drop for PanicsOnThird { + fn drop(&mut self) { + DROPS.fetch_add(1, Ordering::Relaxed); + } + } + + let source: Box> = IdVec::from_raw(alloc::vec![ + PanicsOnThird(1), + PanicsOnThird(2), + PanicsOnThird(3), + ]) + .into_boxed_slice(); + + let outcome = std::panic::catch_unwind(|| source.clone()); + + assert!(outcome.is_err()); + assert_eq!(DROPS.load(Ordering::Relaxed), 2); + drop(source); + assert_eq!(DROPS.load(Ordering::Relaxed), 5); + } } diff --git a/libs/@local/hashql/core/src/id/vec.rs b/libs/@local/hashql/core/src/id/vec.rs index da27b913e5e..9ee5f2a1273 100644 --- a/libs/@local/hashql/core/src/id/vec.rs +++ b/libs/@local/hashql/core/src/id/vec.rs @@ -552,6 +552,22 @@ where self.raw.copy_within((start, end), dst.as_usize()); } + + /// Drains elements from the `range` of the vector. + /// + /// + /// See [`Vec::drain`](std::vec::Vec#method.drain) for details. + /// + /// # Returns + /// + /// An iterator over the drained elements. + #[inline] + pub fn drain(&mut self, range: impl RangeBounds) -> alloc::vec::Drain<'_, T, A> { + let start = range.start_bound().copied().map(Id::as_usize); + let end = range.end_bound().copied().map(Id::as_usize); + + self.raw.drain((start, end)) + } } #[cfg(feature = "rayon")] From 04d8f8c31a2401acc82d332b1e9dd26300ae9483 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:36:00 +0200 Subject: [PATCH 2/2] chore: remove clippy allow attributes in atlas code --- libs/@local/graph/atlas/src/salt/lod/bench.rs | 3 --- libs/@local/graph/atlas/src/salt/postings/closure.rs | 7 ------- 2 files changed, 10 deletions(-) diff --git a/libs/@local/graph/atlas/src/salt/lod/bench.rs b/libs/@local/graph/atlas/src/salt/lod/bench.rs index 6a76aa76fe4..95d90f79a60 100644 --- a/libs/@local/graph/atlas/src/salt/lod/bench.rs +++ b/libs/@local/graph/atlas/src/salt/lod/bench.rs @@ -517,7 +517,6 @@ fn radix_key_order( source } -#[expect(clippy::missing_panics_doc)] impl WalkBench { /// Builds the corpus and runs the production cascade over it. /// @@ -3316,7 +3315,6 @@ impl WalkBench { } } -#[expect(clippy::missing_panics_doc)] impl VisibleCellPyramid { /// Counts the depth's cells inside `cell` holding a visible point. /// @@ -3682,7 +3680,6 @@ impl ServedGeneration { } } -#[expect(clippy::missing_panics_doc)] impl VisibleCascade { /// Returns the tile's scheduled count under the visible-only assignment. /// diff --git a/libs/@local/graph/atlas/src/salt/postings/closure.rs b/libs/@local/graph/atlas/src/salt/postings/closure.rs index 432fdb64620..de5b6284c49 100644 --- a/libs/@local/graph/atlas/src/salt/postings/closure.rs +++ b/libs/@local/graph/atlas/src/salt/postings/closure.rs @@ -68,13 +68,6 @@ pub(crate) struct IconSource { #[derive(Debug, Clone)] pub(crate) struct ClosureMap { /// Reflexive descendant reachability for each ontology row. - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "retained descendant rows are inspected only by test helpers" - ) - )] bits: BitMatrix, icon_sources: IdVec>, memberships: IdVec>>>,