You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A sentinel is not a judge. It stands watch, keeps its bearings, and reports what has changed.
That is the idea behind Spectral Sentinel. It does not decide whether a pattern is dangerous, important, or actionable. It measures structure in a stream, learns what has been ordinary so far, and returns statistical readouts when new observations depart from the learned geometry.
The "spectral" part is literal: each selected region is modelled with low-rank subspace trackers, and a second tier models the spectrum of those scores across related cells. The "sentinel" part is restraint: the crate observes, scores, and reports, but policy stays with the host.
Summary
This PR adds torrust-sentinel to the workspace: a library crate for hierarchical online subspace anomaly detection over positionally structured observation streams. It is rebuilt as a single commit on the current develop (after #884), so it sits on the MSRV-1.89 floor of ADR-T-011 and follows the per-crate versioning of ADR-T-012.
It is built on top of Mudlark. Mudlark provides the adaptive spatial substrate — regions that receive more observation volume earn finer resolution, quiet regions remain coarse. Spectral Sentinel uses that structure to decide where statistical trackers are worth maintaining. It selects significant V-Tree entries, closes them under G-tree ancestry so every selected cell has a complete ancestor chain back to the root, and scores incoming batches against learned subspace models at every selected scale.
The simplest way to think about it: Mudlark decides where the stream has shape; Sentinel measures whether the recent shape still looks like what that region has learned to expect.
The crate is deliberately policy-free. Reports carry raw measurements — four scoring axes (novelty, displacement, surprise, coherence), maturity, baselines, CUSUM drift accumulators, geometry, contour summaries, and health snapshots. They do not encode threat levels, recommended actions, or decisions. The host reads the measurements and decides what they mean.
The core invariant is feed-forward: every input value updates Mudlark with exactly one unit of observation volume. Anomaly scores never flow back into the spatial index. That keeps spatial adaptation driven by traffic structure, not by the detector's own conclusions. Temporal policy is host-controlled: Sentinel never applies decay automatically.
A second analysis tier — coordination trackers — runs at internal G-tree nodes whose subtrees both contribute competitive cells. It scores cross-cell patterns of the four axes, so a coordinated shift that no single cell would flag still surfaces in the report.
Contents
The addition is substantial, but almost entirely self-contained within packages/sentinel.
A new torrust-sentinel crate (1.0.0) with a narrow public surface exposed through flat crate-root re-exports
SpectralSentinel<C, V, N> as the generic engine, with Sentinel128 and Sentinel64 aliases for the common domain widths
SentinelConfig, NoiseSchedule, and SvdStrategy for host-controlled measurement parameters, with structured ConfigError / ConfigErrors / ConfigWarning validation rather than panics
Public readout types for batch, cell, coordination, contour, health, maturity, geometry, baseline, score, and analysis-set summaries — ordered deterministically by GNodeId
Analysis-set selection over Mudlark's G-V Graph: top-K competitive V-entries plus ancestor closure into the investment set
Per-cell subspace trackers scoring novelty, displacement, surprise, and coherence; EWMA baselines with upper-tail clipping; CUSUM drift accumulators with a separate slow EWMA
Automatic synthetic noise warm-up for every newly created tracker, plus a deferred staging area and optional background warming thread so cell creation does not block the ingest hot path
Brand incremental SVD for subspace evolution, with a naive thin-SVD path used both as a fallback for small/numerically sensitive cases and as a debug-mode oracle
Optional serde support (off by default; pulls in torrust-mudlark/serde)
A Criterion benchmark suite (15 bench functions across 8 groups: encoding, ingest, auxiliary, convergence, scaling, temporal, analysis)
21 architecture decision records covering measurement-not-opinions, the feed-forward invariant, Mudlark integration, deterministic ordering, analysis-set recomputation, automatic noise injection, scoring geometry, decay semantics, routing, degenerate-dimension guards, test budgets, warm-up convergence, visibility, cell-creation performance, Brand SVD, deferred warm-up, generic domain parameters, investment-set terminology, and the clip-pressure / mean-centred-variance EWMA refinements
README, public API reference, algorithm document, and implementation notes (~4.7k lines of crate-level documentation total)
642 tests with all features enabled (631 in the default configuration) across crate-level tests (src/tests/), integration tests (tests/) and doc-tests, with the README compiled as a doc-test via #[cfg(doctest)] include_str!
Two pedagogy integration tests (pedagogy.rs, pedagogy_advanced.rs) written to be read end-to-end as a walkthrough of the public surface
Changes outside sentinel
Cargo.toml — packages/sentinel added as a workspace member
packages/mudlark — unchanged: the series rides on the released Mudlark 1.1.0 that develop carries, whose structural mutation counters and semi_internal_count() accessor the report reads; the branch's own earlier cut of that feature is dropped, and no commit in the series touches the Mudlark package
Cargo.lock — 76 entries added for the dependency closure (faer, rand_distr, plus dev-only criterion and tracing-subscriber) against the lockfile develop refreshed under the 1.90 floor; no existing entry moves, and fifteen bare dependency lines gain a version qualifier because the closure introduces a second compatible release of thiserror, thiserror-impl, rand_chacha and r-efi
AGENTS.md — adds Sentinel's S- cross-reference prefix to the package table and ADR examples
Manifest under ADR-T-012
The dependency on the sibling torrust-mudlark pins version = "1.1.0" beside its path, because cargo publish writes that requirement into the published manifest and the report reads the structural mutation counters that arrive with that minor. faer, tracing and criterion name the 0.x line the sources are written against (0.24, 0.1, 0.8) instead of a bare 0, for the reason #884 gave for the root's requirements. cargo publish --dry-run -p torrust-sentinel stops at resolution because torrust-mudlark is not on crates.io yet; that is the publication order ADR-T-012 documents, and torrust-mudlark itself dry-runs cleanly.
From there, the main implementation path is src/sentinel/mod.rs for the orchestrator, src/analysis_set.rs for competitive selection and ancestor closure, src/sentinel/tracker.rs for per-cell scoring, src/sentinel/{cusum,staging,warming_thread}.rs for drift and warm-up, and src/maths/ for the SVD plumbing.
For a focused review, I would look at:
the public API shape and the flat crate-root re-exports
configuration validation, defaults, and the structured error/warning types
the feed-forward Mudlark integration (Δ = 1 per observation, scores never feed back)
report semantics, deterministic ordering, and what is and isn't part of the public surface
tracker warm-up, the deferred staging area, and the optional background warming thread
the Brand SVD fallback boundary (small d, narrow rank gaps) and the debug-mode oracle path
integration tests that assert invariants through the public API only
The pedagogy tests are intended to be readable end-to-end; running cargo test -p torrust-sentinel --test pedagogy -- --nocapture produces a narrated walk through the public surface.
Verification
On the rebuilt commit: cargo fmt --check clean; cargo clippy --workspace --all-targets --all-features -- -D warnings clean under the workspace lint table; the crate's 565 tests and 15 doc-tests pass; the whole workspace passes (2,370 tests, none failed); cargo audit keeps the vulnerability count of develop (the one rsa advisory with no fixed release) and adds a single allowed unmaintained-crate warning, RUSTSEC-2024-0436 (paste, a proc-macro pulled by faer through gemm).
Notes
Ships at 1.0.0: the public surface documented in docs/api.md is covered by semver guarantees from this release onwards. A sibling crate consumes it through a version-beside-path pin, the same discipline this manifest applies to torrust-mudlark, so the version a consumer pins is the one the manifest declares.
MSRV 1.89, inherited from the workspace (ADR-T-011).
No unsafe code; #![forbid(unsafe_code)] at the crate root.
AGPL-3.0-only, inherited from the workspace. Unlike Mudlark, no linking exception is shipped with this crate.
Default features: none. serde is opt-in.
The crate measures only. Interpretation and response remain external host policy.
Temporal policy is host-controlled: Sentinel never applies decay automatically.
Configuration prefers structured errors over panics.
Sentinel docs and ADRs use the S- cross-reference prefix added in this PR.
Review fixes
Since the previous head, nine commits on top of the three original ones fix every finding an automated review of the package produced and a code-level verification confirmed: the configuration validation refuses non-numbers, an unrepresentable depth-buffer headroom and a coordinate width below the tracker minimum (two additive error variants); the full-width cell owns the domain maximum and the root leaves the analysis candidates before the capacity cut; staged cells warm by their real volume and a pass with no competitive scores retires every coordination context; health and batch reports count the online sets, populate the semi-internal count from the graph (one additive mudlark accessor, and the headroom arithmetic in mudlark saturates instead of wrapping), count the whole contour and order coordination reports by depth then identifier; the geometric schedule reports its true maximum, a failed corrective factorisation reports failure so the dispatcher falls back, and the unread round scores are gone. Prose follows the code (the z-score denominator, the live-tracker figure, the open bit-source trait, the implemented dimension guard), and the one exact float equality in the invariants suite compares bit patterns. Nothing on the public surface is removed or reshaped.
Second round of review fixes
Five further commits fix every finding of a second automated review at the previous head. The warming thread's shutdown transition is made under the staging lock its wait is paired with, so a shutdown can no longer be lost between the worker reading its predicate and sleeping on it, which left the join — reached from Drop — waiting forever; the u128 centred-bit conversion caps the requested width at the type's own instead of indexing past its backing array; the centred bit vector gains a validated constructor and a length accessor so an implementation of the open bridge trait outside the crate can return the value its impl must produce; the online summary reports the investment count over the whole selection, warming cells included, as its contract states. The test support's four-bit generator refuses a nibble at sixteen or above (which shifted every set bit out of the coordinate and aliased the range sixteen below), a six-bit generator carries the sprays that claim sixty-four distinct ranges, and the ordering, budget and concentration witnesses assert the documented order, the structure's own budget and a report below the root. The upper bounds of analysis entries and coordination contexts document the top-of-domain exception, the analysis set's full field is named as the investment set it is, the thread-safety plan states the Send + Sync the crate asserts statically, and section-mark references with no referent leave the record and the test banners. Public surface: three additive constant functions on the centred bit vector; nothing is removed or reshaped. Verified on stable 1.98 (the toolchain this repository lints and tests with), 1.89.0 and nightly.
Third round of review fixes
Four further commits fix every finding of a third automated review at the previous head. The headroom a depth pair demands was computed with one checked step and three unchecked ones around it, so a creation depth of zero beside an eviction depth at the top of the range overflowed inside the very method that promises to hand back its faults as values; the computation now lives in a helper whose every step is checked, and any overflow reports the existing structured error for a buffer too large to honour, with the widest pair a budget can clear pinned as accepted. The centred-bit vector holds at most 128 values, but the coordinate trait it is fed from is open to wider types and the only width guard compared the tracker's dimension with the coordinate's declared bits, so a 200-wide tracker over a 256-bit coordinate was admitted and fed from a 128-slot vector; the sentinel now refuses a width above the vector's ceiling with a configuration error naming the width and the maximum, and the ceiling is documented on the bit source, on the bit vector and in the crate docs. The prefix generators in the test support guarded their ranges with assertions that release builds compile out; all three sites assert unconditionally. The dimension guard's doc said widths at or below the minimum are refused where the predicate refuses only widths below it, and now names the side of the boundary that is kept; a bare section ordinal in the exponential-average module is replaced by the sense it carried. Public surface: one additive configuration-error variant; nothing is removed or reshaped. Verified on stable 1.98 (the toolchain this repository lints and tests with), 1.89.0 and nightly.
Fourth round of review fixes
Three further commits correct every finding of a fourth automated review at the previous head; all eight are documentation, and no Rust moves. The two warming modes draw from two different generators: synchronous warming drains from the sentinel's own generator, while background warming draws from a second one seeded on the worker and promotes whatever the worker has finished at each ingest, against a map the main thread is concurrently writing. Four sites promised bit-for-bit reproducibility across runs without saying which mode delivers it; each now scopes the claim to synchronous warming on a fixed build, in one identical clause, and names the background-mode interleaving as the second source of randomness that reaches the scores. Three lifecycle records described mechanisms that no longer run where they said: the cell-width rejection lives in the suffix-width filter applied at runtime rather than in configuration validation, and its effective range is stated; creation schedules noise injection rather than performing it, now that the injection itself is deferred; and the bounded per-ingest work of the deferred warm-up record is stated for the background mode it holds in, with the default mode's in-line drain named beside it. The dimension guard's record said cells at or below the minimum are excluded where the filter keeps a cell at it, and now carries the same words as the constant's own documentation. Verified on stable 1.98 and nightly, with the crate's rustdoc and doc tests, since the README is the crate's front-page documentation.
Fifth round of review fixes
Three further commits correct every finding of a fifth automated review at the previous head. Construction asked the operating system for the background warming thread and aborted the host when the request was refused, over a resource limit that has nothing to do with the configuration's correctness; the request now returns the environment's own account as a configuration error naming the setting, and it arrives alone because the thread is asked for only once validation has passed. Reset, which has no error channel, keeps the sentinel running and warms cells synchronously instead, recording the refusal as a warning: the warm-up dispatch keys on whether a thread is present rather than on the flag, so the fallback is complete and every report is produced as before. Seeding one baseline from another copied the numbers but only ever raised warmth, so a receiver seeded from a cold source stayed warm over placeholder statistics and the cold path that replaces them never ran again; warmth is now part of what is handed over, in both directions, with a witness that fails at the previous head. Three tests and their prose claimed more, or other, than the engine guarantees: the determinism test compared three lengths and a few means where it now compares whole reports figure by figure with equal bit patterns; the coordination-report ordering test asserted ascending handle where the producer sorts by depth and then handle, and both prose statements of the handle-only order are corrected with it; and the reproducibility claim at the top of the determinism suite is scoped to synchronous warming on a fixed build, which is the configuration those tests share. Public surface: one additive configuration-error variant, and the configuration-error enumeration is marked non-exhaustive ahead of first publication so a later refusal is additive too; the warming-thread handle whose signature changed is crate-internal. Verified on stable 1.98 (the whole workspace lints clean; the crate's tests pass), 1.89.0 and nightly, with the crate's rustdoc and doc tests.
Rebased onto the released Mudlark
The series is rebased onto the develop that merged Mudlark 1.1.0. The rebase drops the branch's own cut of the structural mutation counters and the two Mudlark hunks two Sentinel commits carried, keeps every other commit byte-identical in patch, author and order, and re-resolves the lockfile against the refreshed one: the resolver accepts the result unchanged under --locked, and the full bar — nightly tests with and without features, nightly and stable clippy with warnings denied, stable tests, the 1.90 check, and rustdoc with warnings denied — is green at the tip.
Later rounds of review fixes
The remaining rounds of automated review, each verified at code level before a change was made, are answered by the commits after the fifth round. The CUSUM allowance now follows the algorithm text, κσ·√v_slow, with the denominator-protection constant kept out of it; the geometric noise schedule saturates an unrepresentable exponent instead of wrapping it, so a public caller with an arbitrarily deep argument still lands on the floor; coordination contexts are retained by online competitive membership rather than by which cells happened to score in the batch, so a quiet batch no longer destroys a context that the next joint batch would have to re-warm. Every section reference in source and tests uses the qualified §ALGO S-N form and points at the section that carries the cited content; the clip-pressure implementation plan moved to docs/plans/ so the ADR identifier it borrowed resolves to one record; the implementation guide describes the lazy coordination warm-up the code performs; a failed warming worker is recorded rather than allowed to take reset() down; the report's geometry record describes the model that produced the scores beside it; the compile-time width bound is named where a reader would look for it in the error list; and a test comment that cited a document the package never contained now derives its tolerance in place. Finally, the structural mutation counts in the contour snapshot are read from the spatial layer's own counters instead of being inferred from node and terminal deltas, an inference that a last-child eviction falsified; that is what the Mudlark minor bump carries.
The round that followed the rebase is answered by two further commits. The first repoints every stale algorithm citation in the Sentinel records, plans and source comments at the section that now specifies the behaviour each one describes, including the three that named a chapter the document no longer has and the one requirement the algorithm never specified at all. The second orders the warm-up queue by depth before identifier at both promotion sites, so an ancestor that ties with a descendant on volume is warmed first even when arena slot reuse has handed that descendant the smaller identifier, with a witness on each path that fails without the depth comparison.
The notes that round left on unchanged code are answered by four further commits. The first fixes a tracker report that mixed two models, publishing an energy share and a leading singular value read after the subspace had already been replaced, so that every reported model figure now describes the model that actually scored the batch. The second gives the API reference the two re-exported observation types it had never documented, states the real ordering of each report vector against the code that produces it, and extends the configuration-error table from a third of the enum to all of it. The third repoints four records at the specification sections that carry the material they cite, two of which named a section about something else and two a chapter the document no longer has. The fourth brings the batch warmer's equal-volume ordering into line with the two live drains, so an ancestor is warmed before the cells beneath it on whichever path serves the queue.
The round after that left four notes on unchanged records, answered by five further commits. The first states the warm-up ordering the staging area actually keeps, volume then depth then the identifier with the reason each layer exists, in both the deferred-warm-up record and the investment-set record's synchronous-drain decision, so the two describe one ordering rather than two. The second makes the contour snapshot's type-level contract count what its producer counts, terminal nodes together with semi-internal ones, and replaces the configuration record's overflow-prone headroom example with the checked helper and caller the validator actually runs. The third repoints all seventeen citations of the retired chapter 18 at the sections that replaced it. The fourth gives the specification the tie-breaks its ancestor-first guarantee depends on: the priority key becomes the lexicographic triple of volume, depth and node identifier, with the reason each layer is load-bearing. The fifth repoints thirty-four further citations left pointing at sections two renumberings removed, each target verified by reading the section rather than by arithmetic on the number.
The following round is answered by three further commits. The first recovers a sentinel whose warming worker died: a panicked background thread left its handle standing, so every later reconciliation notified a dead thread and every cell staged from then on stayed off the producing set while reports kept arriving; the worker is now reaped where it would otherwise be handed more work, the cells it was holding are rebuilt, and warming continues synchronously for the sentinel's remaining life, with a witness that fails without the detection. The second repoints fifty-six stale algorithm cross-references at the sections that actually specify what each citing line describes, after auditing all four hundred and seventeen citation sites in the package against the document's headings and bodies. The third marks the noise-schedule default's remaining-work row done, so the record stops contradicting the 450 root rounds the configuration ships.
One further commit repairs the recovery's witness, which could not reach its own claim on a small machine: its setup now warms synchronously so every scheduled cell is online before the sentinel crosses into background warming for the strand itself, and every assertion after the strand stands exactly as it did.
The round after that left three notes on unchanged code, answered by four further commits. A checked-out cell now returns with the volume the graph has now, so an ingest landing mid-warm-up can no longer leave the busiest cell queued at its pre-ingest importance. The analysis-set summary type says what each of its producers counts, instead of writing one producer's online scope into fields three producers fill. The three texts justifying the volume refresh now name the tie-breaks that would otherwise decide a newly queued cell, rather than a tie-break the queue no longer has. The specification states that the root is a member of the investment set by construction and never a competitive target, and the sites asserting it cite the section that now carries it.
❌ Patch coverage is 91.76115% with 218 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.37%. Comparing base (843aaff) to head (bab66fa).
The reason will be displayed to describe this comment to others. Learn more.
Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.
The convergence helper cited a document that does not exist, leaving the statistical basis of its block comparison unverifiable. Its nearby numeric explanation also treated a two-block difference as though only one block contributed variance.
Deriving the EWMA transient, autocorrelation inflation, and two-block difference scale beside the helper makes the tolerance checkable from the quantities the test uses. The existing per-axis budgets and test behaviour remain unchanged.
…ounters
Node and terminal population deltas do not identify an eviction that turns a semi-internal parent into a terminal. Reading the spatial layer's monotonic event counters preserves every structural event, while snapshotting each new graph prevents reset from creating a synthetic interval.
The split field counts created children, and unsigned net removals floor restoration-heavy intervals at zero and saturate values outside the report field's range.
An infinite denominator guard passes a positivity check while making every protected score or energy denominator infinite, collapsing the resulting ratios to zero. Validation now separates non-finite values from non-positive finite values and leaves the largest finite guard admissible.
EpsNotFinite is an additive variant of the existing non-exhaustive ConfigError enum, giving callers a truthful diagnostic without reshaping any existing item.
The learned subspace, latent statistics, and score baselines consume the forgetting factor once per tracker batch, while maturity consumed it once per row. Larger batches therefore claimed that warm-up had been forgotten before the model state had forgotten it.
Advance noise influence once per tracker batch while retaining sample-based observation counters. The algorithm previously specified the recurrence per observation even though its convergence table used batches; it now states the model-aligned cadence and its batch-size independence.
Rank adaptation advances once per tracker batch, so describing its interval in observations makes the schedule appear to depend on batch size. State the unit consistently in the public configuration and supporting documents.
Replace internal record and section keys in source documentation with the navigable ADR and package-qualified algorithm references required by the contributor contract.
The algorithm specification was restructured — chapters renumbered, sections moved, and the chapter 18 that once carried the deferred warm-up material removed — while the records and source comments citing it were not swept. Each reference below named a section that no longer specifies the behaviour its own sentence describes, so a reader following one landed on unrelated material and had no route from the record to the specification it depends on.
Each now names the section that does specify it:
- ADR-S-001, polarity convention: §ALGO S-6 becomes §ALGO S-5.1 (Polarity Invariant). Chapter 6 covers baseline tracking and drift detection.
- ADR-S-002, volume accounting and step ordering: §ALGO S-8.1 Step 2 becomes §ALGO S-9.1 Step 2, and §ALGO S-8.2 becomes §ALGO S-9.2 (Step Ordering). The §8 sections define selection sets, not ingest ordering. The unit-delta rule the enforcement note cites as §ALGO S-8.3 is the per-value loop in §ALGO S-9.1 Step 2.
- ADR-S-005, seed parameter: §ALGO S-11.3 becomes §ALGO S-11.1.3 (Parameters), which carries the random seed; §11.3 is clip-width modulation during warm-up.
- ADR-S-006, selector and delivery: §ALGO S-4.2, §ALGO S-4.3 and §ALGO S-4.4 become §ALGO S-8.1 (Competitive Selection), §ALGO S-8.2 (The Investment Set) and §ALGO S-9.3 (Multi-Scale Delivery). §4.4 does not exist, and chapter 4 is the subspace model rather than the selector this record governs. The context sentence cites §8.1 and §8.2 together because it states both the top-K rule and the ancestor closure.
- ADR-S-007, injection triggers: §ALGO S-11.2 becomes §ALGO S-11.1.2 (Trigger Events), the section requiring injection on every new tracker creation; §11.2 is cold-start latent seeding.
- ADR-S-008, scoring axes: §ALGO S-6 becomes §ALGO S-5 (Scoring).
- ADR-S-010, observation delivery: §ALGO S-8.1 becomes §ALGO S-9.1, whose Step 4 delivers suffix vectors along the ancestor path. §8.1 has no steps.
- ADR-S-011, closure and tracker: §ALGO S-4.2 becomes §ALGO S-8.2 for the analysis-set closure, and §ALGO S-5 becomes §ALGO S-4 for the subspace tracker.
- ADR-S-014, tracker and EWMA: §ALGO S-5 becomes §ALGO S-4, and §ALGO S-7.1 becomes §ALGO S-6.1.1 (Update Rule); §7.1 is coordination contexts.
- ADR-S-016, subspace evolution: §ALGO S-5.2 becomes §ALGO S-4.2, whose Phase 2 evolves the subspace; §5.2 is the novelty score.
- ADR-S-017, work variance and timing: §ALGO S-18, §ALGO S-18.4 and §ALGO S-18.5 become §ALGO S-12.9 (Work Variance), which carries both the per-call bound and the design note placing constant-time guarantees outside the specification.
- The API plan's warming-thread row and the warming-thread module header: §ALGO S-18.2 becomes §ALGO S-11.6 (Deferred Cell Warm-Up).
- The CUSUM module header: §ALGO S-7.3 becomes §ALGO S-6.3 (CUSUM Drift Accumulator); §7.3 is coordination running-mean centring.
- The warming handle's thread-safety rationale cited §ALGO S-18.2 Step 3.5 for a requirement the algorithm does not specify at all. It now cites ADR-S-005, which records that `SpectralSentinel` must be `Send + Sync` and states why the algorithm is silent on it.
Only the references move; the surrounding prose stands as written.
…ual volume
The warm-up queue is ordered by cached g.sum so that ancestors come online before the cells beneath them, which is what lets a competitive target be promoted with its whole chain already online. Equal volumes are the ordinary case on such a chain rather than a corner: a path node whose accumulation is entirely the single cell below it ties with that cell exactly, and the cached volume is an f64 approximation of the node sum besides. The tie-break therefore decides the order for precisely the pairs the rule exists to protect.
Both orderings broke the tie on `GNodeId`, reading the smaller identifier as the older and so the shallower cell. The graph's arena does not support that reading. Allocation pops the free list before it grows the slot vector, and deallocation pushes the freed slot and bumps its generation, so identifiers are not monotone in creation order; `GNodeId` orders on the slot index first, so a cell created into a recycled slot sorts below an ancestor allocated before it. Eviction frees G-node slots and splits allocate from the same arena, so the inversion asks for nothing unusual — a cold cell evicted, then a split beneath a live ancestor. On a tie the descendant was then warmed first and could be promoted while its own ancestor was still warming, which is the gap the g.sum ordering exists to close.
Both sites now compare depth before the identifier, and the identifier stays as the final deterministic tie-break between cells of equal depth. Two witnesses construct the shape directly — an ancestor in a first-generation slot, a descendant in a recycled slot holding the smaller identifier, equal volumes — and assert the ancestor is taken first on the background path and reaches the ready queue first under the synchronous drain. Removing either depth comparison fails them both.
The claim recorded for the existing equal-volume test argued from identifiers being handed out as the tree grows downward. That premise is the one that fails, so it is restated in terms of the arena's slot reuse.
Coordination report mixes updated and prior model metrics
packages/sentinel/src/sentinel/tracker.rs:324
evolve_subspace has already replaced self.sigmas before this snapshot, so energy_ratio describes the updated model while rank and geometry describe the prior model that actually produced the scores. This contradicts the public coordination-report contract in docs/api.md:207 and makes one report internally time-skewed. Capture the energy ratio (and, if it is intended to describe the scoring model, the top singular value) before Phase 2, then emit that snapshot.
Document re-exported CentredBitSource and CentredBits APIs
packages/sentinel/docs/api.md:106
The canonical API reference re-exports CentredBitSource and CentredBits here but never documents their public contract or the public CentredBits::{new,len,is_empty,from_u128,suffix} methods; §6 instead lists the observation module as internal machinery. This conflicts with the README's promise that every crate-root re-exported type, trait, and method is documented and semver-covered. Add a public-surface section for these APIs and remove their types from the internal-only table.
Document coordination report ordering by depth and node ID
packages/sentinel/docs/api.md:156
This blanket ordering statement is incorrect for coordination_reports: the producer and determinism test order those reports by ascending depth and then GNodeId, not by handle alone. Document the distinct order so consumers do not incorrectly sort or compare coordination reports.
Document all ConfigError variants
packages/sentinel/docs/api.md:771
This purportedly exhaustive public ConfigError table ends here, but the enum continues with coordination-decay, CUSUM allowance, clipping, split/depth/budget, noise, tracker-width, and warming-thread variants (config.rs:485-534). Since this document is the crate's definitive stable API reference, consumers cannot discover most construction failures from it. Extend the table to cover every current variant.
A tracker report describes one batch: the scores, and the model those scores were measured against. Two of the model figures were read after that model had already been replaced. The scoring geometry is built before any phase touches the state, and the rank is captured from the value that scored, but the energy ratio and the leading singular value were read off the sigmas the subspace evolution leaves behind. One report therefore carried three figures describing the model that scored the batch and two describing the model that will score the next.
The energy ratio is the worse of the two, because it does not describe the evolved model either. It divides the energy held by the leading `rank` sigmas by the total energy, and at the point it was read the rank is still the scoring rank while the sigmas are already the evolved ones — a ratio no model ever held. A host reading the published energy share against the published rank beside it is comparing figures drawn from two different moments, and the gap is widest exactly where the figures matter most, on the batches whose traffic moved the subspace furthest.
The snapshot now happens once, before the evolution, alongside the rank and the geometry that were already taken there. A witness teaches a tracker a single direction, reads the energy ratio and the leading value off the model that is about to score, and then feeds a batch orthogonal to what was learned; the report must carry the figures read beforehand. The same witness requires the evolved model's figures to differ from them, so the equality cannot be satisfied by a model the batch left untouched. Moving the capture back after the evolution fails it.
…rderings and every configuration error
The reference is the crate's definitive account of its stable surface, and the crate's semver promise is written in terms of it: every type, trait and method re-exported from the crate root is covered. A claim the reference makes that the code does not keep is therefore worse than a gap, because a consumer reads it and writes against it. Three such claims are corrected here, each against the code that decides the answer.
`CentredBitSource` and `CentredBits` are re-exported from the crate root with nothing hidden between them and a caller, so they are inside that promise. The reference listed them only as the contents of a crate-private module, which left their obligations nowhere a consumer could read them: the width cap an implementation applies rather than trusts, the fixed hundred-and-twenty-eight-slot backing array that bounds what any implementation can serve, and the two methods that panic together with what provokes them. They now have a section beside the other operational types, and the internal-machinery row states what is true of the module rather than of the types it exports.
The ordering claim covered all three report vectors with one key. The two per-cell vectors do come out in ascending handle order, because they are a single walk of the handle-keyed cell map, partitioned into halves that each keep its order. The coordination vector does not: its producer sorts by ascending depth and only then by handle, so the root leads rather than trailing its own subtree, and the determinism test asserts that order. A consumer that took the blanket claim at its word and re-sorted on the handle alone would discard the depth ordering the vector exists to carry. The determinism section stated the same thing in compressed form and no longer does.
The configuration-error table stopped at a third of the enum. Sixteen variants were absent, among them every coordination-decay, clipping, split, noise-schedule and tracker-width refusal, so most of the ways construction can fail could not be discovered from the document that exists to list them. Each row now states the condition the validator actually tests rather than a reading of the variant's name: which bound is inclusive where a neighbouring one is not, which check is reached only when another has already passed, and which three variants are not validation failures at all but are raised where the coordinate width and the warming thread first become knowable. The table also records that the enum is marked as one that grows, so a reader does not take a complete list for a closed one.
Four records point at specification sections that do not say what the citation claims, and a reader who follows one lands somewhere unrelated and has no way to tell whether the record or the specification moved. Each is repointed at the section that actually carries the material, verified by reading the heading and the section under it rather than by arithmetic on the number.
Two of the four name a section that exists but is about something else. The decision that the engine measures and the host decides cites the parameterisation section for layer responsibilities; those responsibilities are set out one section further on, in the table naming which layer owns each concern. The decision fixing the feed-forward signal cites the suffix-extraction section for what the engine owns as against what it inherits; that division is the responsibility-boundaries table, which is the one that separates the inherited spatial properties from the ones the engine owns. Both of those citations are the more misleading kind, because the target resolves and reads as though it were the intended one until it is checked against the claim.
The other two name sections that are gone. Coordination's inline warm-up is cited as a subsection of the observation chapter, which has no such subsection at all; the procedure — the snapshot of contributing baselines, the Gamma-sampled synthetic rounds, the reset that ends it — is the coordination-specific warm-up section, and the code that generates those synthetic scores already cites it correctly. The staging module's own header cites a chapter the specification no longer has; deferred cell warm-up is a section of the initialisation chapter, and the module implements exactly the lifecycle that section describes.
The absent chapter is cited from seventeen further places across the package — sixteen naming its warm-up section and one naming another of its sections. They are left standing here rather than swept in alongside the correction to a single module header, so that the sweep can be taken as its own change and reviewed as one.
The warm-up queue is ordered by cached volume so that an ancestor comes online before the cells beneath it, and the two drains that serve that queue break a tie on volume by comparing depth before the identifier. The batch warmer broke it on nothing at all. Its comparison ran on volume alone, which leaves every equal-volume pair reported as equal, and the maximum of a run of equals is the last one walked; the warming map is keyed by handle and walked in ascending order, so the round went to the largest identifier in the tie.
In the ordinary allocation order that is the cell beneath. A path node whose whole accumulation is the single cell below it ties with that cell exactly, and the cached volume is an approximation of the node sum besides, so ties are the ordinary case on precisely the chains the ordering exists to protect. The cell below is the one split later and therefore the one holding the larger handle, so the warmer inverted the rule for the common case rather than for a corner of it. A descendant warmed first can complete its schedule and be promoted while its own ancestor is still warming, which is the state the volume ordering exists to prevent.
The comparison now matches the two live sites exactly: volume, then depth, then the identifier as the final deterministic tie-break, each reversed against the ascending walk so the maximum is the shallowest and, among cells of one depth, the smallest handle. A witness takes two handles from the graph itself and runs the equal-volume pair both ways round, requiring the ancestor to take the round in each. The two halves pin different things and neither is redundant: with the ancestor behind the smaller handle — the ordinary allocation order — a comparison on volume alone keeps the wrong cell, and with the handles reversed, which is what a recycled arena slot produces, an identifier tie-break on its own keeps the wrong cell instead. Only depth resolves both, and removing the depth comparison fails the second half while removing the tie-break entirely fails the first.
The function remains test-only and keeps its allowance; whether a helper reached only from tests belongs in production code is a separate question from which cell it picks.
The two live warm-up paths order the warming set by cached volume, then by depth, then by `GNodeId`, but the records that describe that ordering name only its leading term. A reader checking the implementation against them finds two comparisons the records do not account for and cannot tell whether they are the rule or an accident of the code.
Volume leads because a shallow cell accumulates everything beneath it, so ordering on it already places an ancestor at or above every cell in its own subtree. It does not decide the cases the ordering exists for: a path node whose accumulation is entirely the single active cell below it ties with that cell exactly, and cached volume is an approximation of the node sum besides, so equal volumes are the ordinary case on precisely the chains that need ancestor-first ordering. Depth resolves those ties toward the shallower cell. The identifier is the final deterministic tie-break and cannot carry the ancestor rule on its own, because identifiers order on the arena slot index and the arena reuses freed slots, so a descendant created into a recycled slot can hold a smaller identifier than the ancestor allocated before it.
The deferred warm-up record claimed ancestor-first ordering was obtained without encoding depth into the priority key, which is the opposite of what the code now does; it states all three layers and why each is load-bearing. The investment-set record's synchronous-drain decision named volume alone as the match with the background thread; it now names the same three layers, so the two records describe one ordering rather than two.
…date depth the way the guard does
Two pieces of documentation describe work the crate no longer does, and each misleads in the direction of a guarantee the crate actually keeps.
The contour snapshot's type-level contract described leaf cells and named the terminal count as its only source. The producer sums the terminal count with the semi-internal count, because a semi-internal node's unsubdivided half accumulates locally and is a cell on the observable surface in its own right; the specification's contour section settles it the same way, and the `cell_count` field's own documentation already said so. A consumer reading only the type-level text takes the reported spatial resolution to be coarser than it is, and the type-level text is what a reader meets first.
The configuration record's headroom example computed the requirement with unchecked subtraction, an unchecked increment and `usize::pow`. That record exists to argue that user-edited configuration is refused with a structured error rather than a panic, so an example that can overflow on the untrusted depth pair argues against its own decision. The validator computes every step in checked form and maps an unrepresentable requirement to a `DepthBufferTooLarge` error; the example now shows that helper together with the caller that reads its result, so the record mirrors the validation callers actually get.
…hapter
The specification ends at chapter 17, and seventeen places across the package still cite chapter 18 — sixteen of them its deferred warm-up section, one its timing section. A reader who follows any of them lands nowhere and cannot tell whether the citation or the specification moved.
Each site is repointed at the section whose content its own sentence describes, read rather than derived from the number. Deferred warm-up as a whole is §11.6. The sites that name a step resolve to the subsection carrying that step: promotion at the start of the observation cycle is §11.6.3, the volume-ordered service of the warming set is §11.6.2, and the background thread — with the synchronous drain and the flag that chooses between them — is §11.6.8, which specifies pipeline advancement independently of observation cadence and names the mechanisms permitted to achieve it. The noise schedule's forms and bounds are §11.1.3. The crate's timing-equalisation note belongs with the work-variance bound at §12.9, whose design note is the statement that deferral does not make the observation algorithm constant-time.
The step numbers attached to those citations are dropped rather than carried across: the replacement subsections do not number their steps, so a step number beside one of them would claim a structure the specification does not have. The implementation record's deferred warm-up heading cited §11.6 and the retired section side by side, and the retired half is simply removed.
The deferred warm-up priority is specified as g.sum alone, with ancestor-first ordering presented as a consequence obtained without encoding depth into the priority. That argument holds only for pairs whose sums differ. By the summation invariant an ancestor's sum is at least as great as any descendant's, and equality is the ordinary case on an ancestor chain rather than a corner of it: a path node whose accumulation is entirely the single warming cell below it has exactly that cell's sum, and an implementation comparing a floating-point approximation of the node sum ties over a wider set still. On those pairs — precisely the ones the rule exists to order — a volume-only key leaves the outcome unspecified, and a reader implementing it faithfully can promote a descendant ahead of its own ancestor.
The priority key is now the lexicographic triple of volume, depth and node identifier. Depth resolves the equal-volume tie toward the shallower cell, which is what makes the ordering a topological sort of the ancestor chains rather than merely consistent with one. The identifier is a deterministic tie-break between cells of one depth and cannot stand in for depth, because identifiers are drawn from an arena that reuses freed slots: a cell created into a recycled slot may hold a smaller identifier than an ancestor allocated before it, so an identifier tie-break alone would warm that descendant first.
The consequence list was introduced as four and carries five entries; the count is corrected in the same edit rather than left contradicting the list it introduces.
Thirty-four citations across the package name sections the specification no longer has, in six groups left from renumbering: chapter 4 now ends at 4.3, the baseline material once in chapter 7 is chapter 6, and chapter 9 ends at 9.5. A reader who follows one of them lands nowhere and cannot tell whether the citation or the specification moved. Each is repointed at the section whose content its own sentence describes, verified by reading the heading and the section under it rather than by arithmetic on the number.
The multi-scale delivery claim — an observation reaching every tracker on its G-Tree ancestor path — is §9.3, which states it almost verbatim. The two citations of a range for ancestor chain properties become chapter 16, whose opening is that it analyses the properties emerging from the guaranteed chain of models from each competitive cell to the root. Cells that receive no observations in a batch, and so contribute nothing to it, are the edge case at §8.8.
The nine root citations divide by which half of the property the line uses. The root tracker's permanence is §8.4, which states it is created at system initialisation and never destroyed. The exception that spares it when exited cells are destroyed is §8.5, which is the rule the reconciliation sites are an instance of. Its membership as the fixed term of the ancestor closure, rather than as something won by competition, is §8.2, whose formula opens the investment set with the root. That last group also asserts the root is never competitive, which no section states; the citation names the section that specifies how the root is in the set, and the gap is left standing rather than papered over.
The convergence benchmark record and the convergence test modules cite the EWMA outlier filter and its clipping ceiling, which are the upper-tail clip filter and ceiling formula in §6.1.1, and the z-score formula quoted in the implementation document is §6.1.2.
Coordination warm-up is §11.7. The sites naming the procedure — the baseline snapshot, the Gamma-sampled score vectors, the cold-baseline defaults whose values the section lists — cite §11.7.2; the sites naming the lifecycle, that a context materialising in the coordination walk is warmed inline before its first real observation, cite the section itself.
This remaining-work item is already implemented: NoiseSchedule::default() uses 450 root rounds in src/config.rs:126-140. Leaving it marked high-priority TODO makes the implemented ADR contradict the shipped configuration; mark it completed or remove it.
The warm-up dispatch in the reconciliation path keys on whether a warming worker is present, and a worker that panicked leaves its handle standing. Every later reconciliation then takes the background branch: it skips the synchronous drain and notifies a condition variable nobody is waiting on, so every cell staged from that point on waits for a thread that will never take it. The engine goes on producing reports from a cell set that has stopped growing, which is the worst shape a failure can take — nothing raises, nothing stops, and the reports stay plausible.
Reaping the handle at the dispatch point restores a state the engine already has an answer for. An absent handle is a complete fallback rather than a half-state, which is the reading `reset` already relies on when the environment refuses it a thread: the drain runs inline, every staged cell still comes online, and every report is produced as it would have been. What a host loses is the latency background warming was enabled for, and it is told so.
The fallback stands for the rest of the sentinel's epoch rather than respawning, because nothing at the dispatch point can tell the two ways a worker dies apart. Its only panics are its own reads of a poisoned staging mutex, and a mutex poisoned by whatever killed the first worker is still poisoned for the second: a respawn on each reconciliation would log a fresh failure every batch and still never warm a cell. `reset` remains the caller's way back to a background worker, and it is explicit.
A cell the worker had checked out goes down with it — its tracker lived on that thread's stack, and only that thread could have returned it. The checkout record outlives both, and while it stands the staging area answers that the cell is present, so the enqueue loop skips an identifier nothing holds and the cell is stranded between the producing set and the staging area permanently. Dropping the record before that loop lets the cell be built again in the same pass, at the cost of the warm-up rounds it had already run — the trade eviction in flight already makes.
The witness builds the stranded state rather than waiting for a panic to land inside the few instructions that produce it, because whether a panic lands there is the scheduler's decision and a test that waited for one would assert about the box it ran on. What the recovery has to handle is the state, and the state is fully described.
A cross-reference earns its keep by landing a reader on the text that settles the question the citing line raises. Every citation corrected here resolves to a real heading and has done so all along, which is why none of them failed a build or a link check: the section number is well-formed and the section exists, it simply specifies something else. That is the worst kind of stale reference, because it is invisible to every tool and costs a reader the walk to the wrong place and back — and on a specification this size, the walk is not short.
The corrections fall into a few families, each a section number that reads plausibly beside the text it was attached to. Competitive selection is §8.1, not §4.1, which is the subspace tracker's state; ancestor closure is §8.2, not §4.2, which is the five-phase core loop. Suffix extraction is §2.4, not §3.2, which partitions the domain — and the spatial substrate itself is §3.2, not §2.4, the same pair crossed in the other direction. Coordination contexts are §7.1 and running-mean centring is §7.3, not §9.1 and §9.3, which are the ingest procedure and multi-scale delivery; bottom-up coordination assembly is §7.4, not §9.4, which is the per-value scoring pipeline, and the chapter is §7, not §9. Post-noise CUSUM seeding is §11.4, whose heading names it, not §7.4. Latent cold-to-warm initialisation is Phase 3 of §4.2, not §5.2, which defines novelty. The system-level warm-up stages are §11.8, not §11.6, which defers individual cells. Noise injection is §11.1, not §11.2, which seeds latent statistics on a tracker's first real batch.
Two families were found by auditing the sites that carried no complaint. The implementation record's baseline-tracking chapter cited §7.1–7.3 for fast EWMA, slow EWMA and CUSUM drift, which are §6.1–6.3 — the coordination chapter's numbering read one chapter off, three sections running. And the step markers in the ingest path cite what each step does rather than the procedure that contains it, which the procedure itself already names: spatial volume accounting at Step 2 is §3.3, and coordination at Step 5 is §7.4.
Only citation strings change. Where a citing line's content has no single home in the specification, the citation is left as it stands rather than repointed on a guess.
The record's remaining-work table still carries the increase of the root noise-round default from 50 to at least 400 at λ = 0.99 as its one HIGH-priority TODO, while `NoiseSchedule::default()` has shipped a geometric schedule at 450 root rounds for long enough that the algorithm specification quotes the figure in two places — the noise-schedule parameter table and the rank-convergence design note that reasons from 450 rounds at root.
A record that contradicts the configuration it describes costs more than an out-of-date line. The row is the table's only HIGH item, so a reader deciding where the remaining risk sits is pointed at the one thing that carries none; and a reader who trusts the row reads the shipped default as unfixed and re-derives a convergence argument the default already satisfies with the margin the constructor's own documentation states. Striking the row in the convention its neighbours already use, and naming the value that shipped, settles both readings at once.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
In-flight warm-up cells retain stale priority volumes across concurrent ingests, and several public API contracts remain inconsistent with staged-cell behavior.
Summary type mixes selection counts with online tracker counts
packages/sentinel/src/report.rs:779
AnalysisSet::summary() also returns this public type, but that method counts the whole selection whether or not trackers are online. Consequently its competitive_size and full_size values do not satisfy this type's “online” field contracts, and investment_set_size is a selection count rather than necessarily a count of allocated trackers. Please separate the selection and producing summary types, or define these fields context-neutrally and document how each producer populates them.
In-flight volume updates are lost when cells return to warming
packages/sentinel/src/sentinel/staging.rs:415
An ingest can run while the worker has removed a cell from warming, so this loop cannot refresh that cell's cached volume. return_warming then restores the old value, and the worker may immediately choose its next batch using pre-ingest importance, delaying a cell that has just become hottest and violating the documented volume-first policy. Please retain the latest volume for in-flight IDs and apply it when the cell is returned; a checkout → graph update → return regression test would pin the interleaving.
Test rationale uses outdated zero-volume tie-break behavior
packages/sentinel/src/sentinel/README.md:33
This test-index claim still says zero-volume ties put the newest/deepest cell first, but the queue now breaks ties by shallower depth and then smaller GNodeId. Please update the rationale to say that missing the refresh would make deterministic tie-breaks, rather than traffic volume, decide newly queued cells.
…ead of racing for it
The witness needs a producing cell below the root before it can strand one on a dying worker, and it reached that state by ingesting eight batches with background warming on and expecting the worker to have promoted something by the time the helper looked. Nothing in the engine makes that true. The reconciliation notifies the worker and promotes whatever is already ready in the same breath, so whether any cell is online at that instant is the scheduler's decision, and on a machine with few cores the answer is no: the helper finds nothing to strand and the witness fails on its own precondition instead of on its claim. That is the failure a witness can least afford, because it says nothing about the recovery it was written to measure.
The starting state is built now. The setup ingests run with no worker, which sends the reconciliation down the synchronous drain, so every cell the schedule asks for is online by the time each call returns, on any machine and at any speed. Nothing is lost by moving them: the analysis set is a function of the value stream alone, so the same eight batches name the same cells they always did, and only the moment those cells come online has stopped being a bet. A worker is started after them, so the strand still runs against a live one, the failure it induces is still the worker's own poisoned-staging path, and the recovery measured afterwards is still the one that follows a worker that died holding a cell.
Every assertion after the strand is unchanged — the stranded cell must come back, every cell the selector pays for must be online, and the staging area must be empty — and the witness still detects what it was written for. Forcing the failed-worker detection to answer not-finished fails it at the first of those assertions rather than at the precondition, which is the whole difference between a witness that cannot reach its claim and one that makes it.
This is the same reading the recovery's own witness already applies one step further in: the stranded state is built rather than waited for, because whether a panic lands in the few instructions that produce it is the scheduler's decision. The state the strand starts from was still being waited for, and it is now built too.
The affordance that crosses a sentinel into background warming is confined to test builds and moves the configuration flag with the handle, because that flag is what reset reads to decide whether to spawn again; a flag left disagreeing with the field would make reset the one call that silently changed the mode.
…s now
The staging area caches each warming cell's volume so the queue can spend the next warm-up round on the busiest cell. `update_volumes` refreshes that cache from the graph for every cell in the warming map, but a cell the background worker has checked out is not in that map: it lives in `in_flight` as a marker while the worker holds the cell itself, and the refresh cannot reach it. The worker then hands the cell back with the volume it carried out, so an ingest landing during the noise injection — the expensive part of a pass, and therefore the part most likely to overlap a checkout — leaves the cell queued at its pre-ingest importance. The next checkout, which is the one decision the cached volume exists to make, can then go to a rival the traffic has already passed, against the volume-first rule the queue serves.
The in-flight record already carries the competitive flag for exactly this reason: reconciliation keeps learning about a cell while the worker owns it, and both return paths copy what it learned back. Volume is the same kind of fact, so it is recorded beside the flag rather than in a structure of its own, and it is seeded at checkout with the volume carried out — a return then always applies the record and never has to ask whether a refresh happened in between. Eviction while in flight still discards the cell, because the record goes with it and there is nothing left to apply, and a completed cell still takes only the flag, because the ready queue is served in the order it was filled and has no priority to honour.
Both refreshes read the graph through one function, so a waiting cell and a checked-out cell cannot come to hold volumes derived differently — including for a node the graph no longer has, which reads as nought to both.
…unts
The summary type documented its fields as online readings — the producing competitive set, the producing full set, the allocated trackers — but three producers fill it, and only one of them reads that way. `AnalysisSet::summary_online()` is the online reading the batch report is built from; `AnalysisSet::summary()`, public and reachable through the sentinel's analysis-set accessor, takes every figure over the whole selection whether or not a cell has a tracker yet; and the batch report then replaces two fields with figures the sentinel can see directly and a selection snapshot cannot. A caller of the whole-selection reading was therefore handed counts whose documented meaning they do not have.
Both readings are deliberate — their own documentation says the difference between them is exactly what a caller has to choose — so the honest repair is to stop writing one producer's scope into the type. The fields now say what they count, the type says which producer supplies which scope, and the figure that is never narrowed by online status says so once, in the place a reader of any of the three would look.
Documenting rather than narrowing keeps the whole-selection reading available. Removing it, or renaming it out of the way, would take the investment-set view from every caller in order to repair a sentence, and would break a public method whose own documentation was already accurate about what it returns.
Three texts justify refreshing the cached volumes after the enqueue loop rather than before it, and two of them argued from a tie-break the queue no longer has: they said a field of zero volumes would be decided in favour of the newest and deepest cell. The queue now resolves equal volumes toward the shallower cell and then the smaller identifier, so the stated consequence is not merely out of date, it is the opposite of what would happen.
The reason survives the correction intact, and is worth stating accurately because it is what makes the ordering of the two steps matter: a cell enters the queue at zero volume, and with the whole field at zero the deterministic tie-breaks decide the order in full. Which cell is warmed first would then be settled by where the cells sit in the tree rather than by the traffic behind them, which is the one thing the cached volume exists to prevent. The third text said only that the queue could not prioritise by traffic, which is true but silent about what takes over instead; it now names the tie-breaks as well, so all three give the same reason.
The implementation drops the root before the top-K cut, a test asserts that it never appears in the competitive set, and the API document states it as a rule — but the specification never said it. §8.1 defined the eligible set by V-Tree depth and suffix width, both of which the root satisfies, so the document as written admitted the root into the competition it is in fact barred from, and the code that bars it had no clause to cite.
The exclusion belongs where the competitive targets are defined, because that is the set it constrains: a union that adds the root to the investment set is silent about whether the root could also have arrived through competition. It is stated in §8.1 as a predicate on the eligible set, with the reason beside it — the root is the whole domain, so it has nothing to be ranked against; it answers the population-level question rather than a regional one and is permanent, which leaves nothing for a competitive slot to decide; and it belongs to the investment set by construction in any case. The order of the operations is part of the rule rather than an implementation detail: the root's importance freezes at its first split while its children start from zero, so an exclusion applied after the cut would hold a slot until a child overtook a frozen total, and at a capacity of one the competitive set would never fill at all.
The citations follow the clause. Sites that cited the ancestor-closure section for the root never being competitive now cite the section that says so, while the halves of those sentences about the root's presence in the investment set, and about its permanence, keep the sections that carry them.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
Out-of-domain narrow-width coordinates can update Mudlark totals while being omitted from every Sentinel tracker, violating the feed-forward reporting invariant.
Get a fresh assessment by requesting another Copilot review.
/// **Why not projection energy / "normality"?** Under the sentinel's
/// centred binary encoding, every observation has the same L2 norm
/// (`d / 4`). Projection energy is therefore a perfect affine function
/// of residual energy — it carries zero independent information.
/// See `docs/algorithm.md`, Appendix B for the full proof.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A sentinel is not a judge. It stands watch, keeps its bearings, and reports what has changed.
That is the idea behind Spectral Sentinel. It does not decide whether a pattern is dangerous, important, or actionable. It measures structure in a stream, learns what has been ordinary so far, and returns statistical readouts when new observations depart from the learned geometry.
The "spectral" part is literal: each selected region is modelled with low-rank subspace trackers, and a second tier models the spectrum of those scores across related cells. The "sentinel" part is restraint: the crate observes, scores, and reports, but policy stays with the host.
Summary
This PR adds
torrust-sentinelto the workspace: a library crate for hierarchical online subspace anomaly detection over positionally structured observation streams. It is rebuilt as a single commit on the currentdevelop(after #884), so it sits on the MSRV-1.89 floor of ADR-T-011 and follows the per-crate versioning of ADR-T-012.It is built on top of Mudlark. Mudlark provides the adaptive spatial substrate — regions that receive more observation volume earn finer resolution, quiet regions remain coarse. Spectral Sentinel uses that structure to decide where statistical trackers are worth maintaining. It selects significant V-Tree entries, closes them under G-tree ancestry so every selected cell has a complete ancestor chain back to the root, and scores incoming batches against learned subspace models at every selected scale.
The simplest way to think about it: Mudlark decides where the stream has shape; Sentinel measures whether the recent shape still looks like what that region has learned to expect.
The crate is deliberately policy-free. Reports carry raw measurements — four scoring axes (novelty, displacement, surprise, coherence), maturity, baselines, CUSUM drift accumulators, geometry, contour summaries, and health snapshots. They do not encode threat levels, recommended actions, or decisions. The host reads the measurements and decides what they mean.
The core invariant is feed-forward: every input value updates Mudlark with exactly one unit of observation volume. Anomaly scores never flow back into the spatial index. That keeps spatial adaptation driven by traffic structure, not by the detector's own conclusions. Temporal policy is host-controlled: Sentinel never applies decay automatically.
A second analysis tier — coordination trackers — runs at internal G-tree nodes whose subtrees both contribute competitive cells. It scores cross-cell patterns of the four axes, so a coordinated shift that no single cell would flag still surfaces in the report.
Contents
The addition is substantial, but almost entirely self-contained within
packages/sentinel.torrust-sentinelcrate (1.0.0) with a narrow public surface exposed through flat crate-root re-exportsSpectralSentinel<C, V, N>as the generic engine, withSentinel128andSentinel64aliases for the common domain widthsSentinelConfig,NoiseSchedule, andSvdStrategyfor host-controlled measurement parameters, with structuredConfigError/ConfigErrors/ConfigWarningvalidation rather than panicsGNodeIdserdesupport (off by default; pulls intorrust-mudlark/serde)src/tests/), integration tests (tests/) and doc-tests, with the README compiled as a doc-test via#[cfg(doctest)] include_str!pedagogy.rs,pedagogy_advanced.rs) written to be read end-to-end as a walkthrough of the public surfaceChanges outside sentinel
Cargo.toml—packages/sentineladded as a workspace memberpackages/mudlark— unchanged: the series rides on the released Mudlark 1.1.0 thatdevelopcarries, whose structural mutation counters andsemi_internal_count()accessor the report reads; the branch's own earlier cut of that feature is dropped, and no commit in the series touches the Mudlark packageCargo.lock— 76 entries added for the dependency closure (faer,rand_distr, plus dev-onlycriterionandtracing-subscriber) against the lockfiledeveloprefreshed under the 1.90 floor; no existing entry moves, and fifteen bare dependency lines gain a version qualifier because the closure introduces a second compatible release ofthiserror,thiserror-impl,rand_chachaandr-efiAGENTS.md— adds Sentinel'sS-cross-reference prefix to the package table and ADR examplesManifest under ADR-T-012
The dependency on the sibling
torrust-mudlarkpinsversion = "1.1.0"beside itspath, becausecargo publishwrites that requirement into the published manifest and the report reads the structural mutation counters that arrive with that minor.faer,tracingandcriterionname the 0.x line the sources are written against (0.24, 0.1, 0.8) instead of a bare0, for the reason #884 gave for the root's requirements.cargo publish --dry-run -p torrust-sentinelstops at resolution becausetorrust-mudlarkis not on crates.io yet; that is the publication order ADR-T-012 documents, andtorrust-mudlarkitself dry-runs cleanly.Reviewing this
The best starting point is the public surface:
packages/sentinel/src/lib.rs→packages/sentinel/docs/api.md→packages/sentinel/README.mdFrom there, the main implementation path is
src/sentinel/mod.rsfor the orchestrator,src/analysis_set.rsfor competitive selection and ancestor closure,src/sentinel/tracker.rsfor per-cell scoring,src/sentinel/{cusum,staging,warming_thread}.rsfor drift and warm-up, andsrc/maths/for the SVD plumbing.For a focused review, I would look at:
d, narrow rank gaps) and the debug-mode oracle pathThe pedagogy tests are intended to be readable end-to-end; running
cargo test -p torrust-sentinel --test pedagogy -- --nocaptureproduces a narrated walk through the public surface.Verification
On the rebuilt commit:
cargo fmt --checkclean;cargo clippy --workspace --all-targets --all-features -- -D warningsclean under the workspace lint table; the crate's 565 tests and 15 doc-tests pass; the whole workspace passes (2,370 tests, none failed);cargo auditkeeps the vulnerability count ofdevelop(the onersaadvisory with no fixed release) and adds a single allowed unmaintained-crate warning, RUSTSEC-2024-0436 (paste, a proc-macro pulled byfaerthroughgemm).Notes
1.0.0: the public surface documented indocs/api.mdis covered by semver guarantees from this release onwards. A sibling crate consumes it through aversion-beside-pathpin, the same discipline this manifest applies totorrust-mudlark, so the version a consumer pins is the one the manifest declares.unsafecode;#![forbid(unsafe_code)]at the crate root.serdeis opt-in.S-cross-reference prefix added in this PR.Review fixes
Since the previous head, nine commits on top of the three original ones fix every finding an automated review of the package produced and a code-level verification confirmed: the configuration validation refuses non-numbers, an unrepresentable depth-buffer headroom and a coordinate width below the tracker minimum (two additive error variants); the full-width cell owns the domain maximum and the root leaves the analysis candidates before the capacity cut; staged cells warm by their real volume and a pass with no competitive scores retires every coordination context; health and batch reports count the online sets, populate the semi-internal count from the graph (one additive
mudlarkaccessor, and the headroom arithmetic inmudlarksaturates instead of wrapping), count the whole contour and order coordination reports by depth then identifier; the geometric schedule reports its true maximum, a failed corrective factorisation reports failure so the dispatcher falls back, and the unread round scores are gone. Prose follows the code (the z-score denominator, the live-tracker figure, the open bit-source trait, the implemented dimension guard), and the one exact float equality in the invariants suite compares bit patterns. Nothing on the public surface is removed or reshaped.Second round of review fixes
Five further commits fix every finding of a second automated review at the previous head. The warming thread's shutdown transition is made under the staging lock its wait is paired with, so a shutdown can no longer be lost between the worker reading its predicate and sleeping on it, which left the join — reached from
Drop— waiting forever; theu128centred-bit conversion caps the requested width at the type's own instead of indexing past its backing array; the centred bit vector gains a validated constructor and a length accessor so an implementation of the open bridge trait outside the crate can return the value its impl must produce; the online summary reports the investment count over the whole selection, warming cells included, as its contract states. The test support's four-bit generator refuses a nibble at sixteen or above (which shifted every set bit out of the coordinate and aliased the range sixteen below), a six-bit generator carries the sprays that claim sixty-four distinct ranges, and the ordering, budget and concentration witnesses assert the documented order, the structure's own budget and a report below the root. The upper bounds of analysis entries and coordination contexts document the top-of-domain exception, the analysis set's full field is named as the investment set it is, the thread-safety plan states theSend + Syncthe crate asserts statically, and section-mark references with no referent leave the record and the test banners. Public surface: three additive constant functions on the centred bit vector; nothing is removed or reshaped. Verified on stable 1.98 (the toolchain this repository lints and tests with), 1.89.0 and nightly.Third round of review fixes
Four further commits fix every finding of a third automated review at the previous head. The headroom a depth pair demands was computed with one checked step and three unchecked ones around it, so a creation depth of zero beside an eviction depth at the top of the range overflowed inside the very method that promises to hand back its faults as values; the computation now lives in a helper whose every step is checked, and any overflow reports the existing structured error for a buffer too large to honour, with the widest pair a budget can clear pinned as accepted. The centred-bit vector holds at most 128 values, but the coordinate trait it is fed from is open to wider types and the only width guard compared the tracker's dimension with the coordinate's declared bits, so a 200-wide tracker over a 256-bit coordinate was admitted and fed from a 128-slot vector; the sentinel now refuses a width above the vector's ceiling with a configuration error naming the width and the maximum, and the ceiling is documented on the bit source, on the bit vector and in the crate docs. The prefix generators in the test support guarded their ranges with assertions that release builds compile out; all three sites assert unconditionally. The dimension guard's doc said widths at or below the minimum are refused where the predicate refuses only widths below it, and now names the side of the boundary that is kept; a bare section ordinal in the exponential-average module is replaced by the sense it carried. Public surface: one additive configuration-error variant; nothing is removed or reshaped. Verified on stable 1.98 (the toolchain this repository lints and tests with), 1.89.0 and nightly.
Fourth round of review fixes
Three further commits correct every finding of a fourth automated review at the previous head; all eight are documentation, and no Rust moves. The two warming modes draw from two different generators: synchronous warming drains from the sentinel's own generator, while background warming draws from a second one seeded on the worker and promotes whatever the worker has finished at each ingest, against a map the main thread is concurrently writing. Four sites promised bit-for-bit reproducibility across runs without saying which mode delivers it; each now scopes the claim to synchronous warming on a fixed build, in one identical clause, and names the background-mode interleaving as the second source of randomness that reaches the scores. Three lifecycle records described mechanisms that no longer run where they said: the cell-width rejection lives in the suffix-width filter applied at runtime rather than in configuration validation, and its effective range is stated; creation schedules noise injection rather than performing it, now that the injection itself is deferred; and the bounded per-ingest work of the deferred warm-up record is stated for the background mode it holds in, with the default mode's in-line drain named beside it. The dimension guard's record said cells at or below the minimum are excluded where the filter keeps a cell at it, and now carries the same words as the constant's own documentation. Verified on stable 1.98 and nightly, with the crate's rustdoc and doc tests, since the README is the crate's front-page documentation.
Fifth round of review fixes
Three further commits correct every finding of a fifth automated review at the previous head. Construction asked the operating system for the background warming thread and aborted the host when the request was refused, over a resource limit that has nothing to do with the configuration's correctness; the request now returns the environment's own account as a configuration error naming the setting, and it arrives alone because the thread is asked for only once validation has passed. Reset, which has no error channel, keeps the sentinel running and warms cells synchronously instead, recording the refusal as a warning: the warm-up dispatch keys on whether a thread is present rather than on the flag, so the fallback is complete and every report is produced as before. Seeding one baseline from another copied the numbers but only ever raised warmth, so a receiver seeded from a cold source stayed warm over placeholder statistics and the cold path that replaces them never ran again; warmth is now part of what is handed over, in both directions, with a witness that fails at the previous head. Three tests and their prose claimed more, or other, than the engine guarantees: the determinism test compared three lengths and a few means where it now compares whole reports figure by figure with equal bit patterns; the coordination-report ordering test asserted ascending handle where the producer sorts by depth and then handle, and both prose statements of the handle-only order are corrected with it; and the reproducibility claim at the top of the determinism suite is scoped to synchronous warming on a fixed build, which is the configuration those tests share. Public surface: one additive configuration-error variant, and the configuration-error enumeration is marked non-exhaustive ahead of first publication so a later refusal is additive too; the warming-thread handle whose signature changed is crate-internal. Verified on stable 1.98 (the whole workspace lints clean; the crate's tests pass), 1.89.0 and nightly, with the crate's rustdoc and doc tests.
Rebased onto the released Mudlark
The series is rebased onto the
developthat merged Mudlark 1.1.0. The rebase drops the branch's own cut of the structural mutation counters and the two Mudlark hunks two Sentinel commits carried, keeps every other commit byte-identical in patch, author and order, and re-resolves the lockfile against the refreshed one: the resolver accepts the result unchanged under--locked, and the full bar — nightly tests with and without features, nightly and stable clippy with warnings denied, stable tests, the 1.90 check, and rustdoc with warnings denied — is green at the tip.Later rounds of review fixes
The remaining rounds of automated review, each verified at code level before a change was made, are answered by the commits after the fifth round. The CUSUM allowance now follows the algorithm text, κσ·√v_slow, with the denominator-protection constant kept out of it; the geometric noise schedule saturates an unrepresentable exponent instead of wrapping it, so a public caller with an arbitrarily deep argument still lands on the floor; coordination contexts are retained by online competitive membership rather than by which cells happened to score in the batch, so a quiet batch no longer destroys a context that the next joint batch would have to re-warm. Every section reference in source and tests uses the qualified
§ALGO S-Nform and points at the section that carries the cited content; the clip-pressure implementation plan moved todocs/plans/so the ADR identifier it borrowed resolves to one record; the implementation guide describes the lazy coordination warm-up the code performs; a failed warming worker is recorded rather than allowed to takereset()down; the report's geometry record describes the model that produced the scores beside it; the compile-time width bound is named where a reader would look for it in the error list; and a test comment that cited a document the package never contained now derives its tolerance in place. Finally, the structural mutation counts in the contour snapshot are read from the spatial layer's own counters instead of being inferred from node and terminal deltas, an inference that a last-child eviction falsified; that is what the Mudlark minor bump carries.The round that followed the rebase is answered by two further commits. The first repoints every stale algorithm citation in the Sentinel records, plans and source comments at the section that now specifies the behaviour each one describes, including the three that named a chapter the document no longer has and the one requirement the algorithm never specified at all. The second orders the warm-up queue by depth before identifier at both promotion sites, so an ancestor that ties with a descendant on volume is warmed first even when arena slot reuse has handed that descendant the smaller identifier, with a witness on each path that fails without the depth comparison.
The notes that round left on unchanged code are answered by four further commits. The first fixes a tracker report that mixed two models, publishing an energy share and a leading singular value read after the subspace had already been replaced, so that every reported model figure now describes the model that actually scored the batch. The second gives the API reference the two re-exported observation types it had never documented, states the real ordering of each report vector against the code that produces it, and extends the configuration-error table from a third of the enum to all of it. The third repoints four records at the specification sections that carry the material they cite, two of which named a section about something else and two a chapter the document no longer has. The fourth brings the batch warmer's equal-volume ordering into line with the two live drains, so an ancestor is warmed before the cells beneath it on whichever path serves the queue.
The round after that left four notes on unchanged records, answered by five further commits. The first states the warm-up ordering the staging area actually keeps, volume then depth then the identifier with the reason each layer exists, in both the deferred-warm-up record and the investment-set record's synchronous-drain decision, so the two describe one ordering rather than two. The second makes the contour snapshot's type-level contract count what its producer counts, terminal nodes together with semi-internal ones, and replaces the configuration record's overflow-prone headroom example with the checked helper and caller the validator actually runs. The third repoints all seventeen citations of the retired chapter 18 at the sections that replaced it. The fourth gives the specification the tie-breaks its ancestor-first guarantee depends on: the priority key becomes the lexicographic triple of volume, depth and node identifier, with the reason each layer is load-bearing. The fifth repoints thirty-four further citations left pointing at sections two renumberings removed, each target verified by reading the section rather than by arithmetic on the number.
The following round is answered by three further commits. The first recovers a sentinel whose warming worker died: a panicked background thread left its handle standing, so every later reconciliation notified a dead thread and every cell staged from then on stayed off the producing set while reports kept arriving; the worker is now reaped where it would otherwise be handed more work, the cells it was holding are rebuilt, and warming continues synchronously for the sentinel's remaining life, with a witness that fails without the detection. The second repoints fifty-six stale algorithm cross-references at the sections that actually specify what each citing line describes, after auditing all four hundred and seventeen citation sites in the package against the document's headings and bodies. The third marks the noise-schedule default's remaining-work row done, so the record stops contradicting the 450 root rounds the configuration ships.
One further commit repairs the recovery's witness, which could not reach its own claim on a small machine: its setup now warms synchronously so every scheduled cell is online before the sentinel crosses into background warming for the strand itself, and every assertion after the strand stands exactly as it did.
The round after that left three notes on unchanged code, answered by four further commits. A checked-out cell now returns with the volume the graph has now, so an ingest landing mid-warm-up can no longer leave the busiest cell queued at its pre-ingest importance. The analysis-set summary type says what each of its producers counts, instead of writing one producer's online scope into fields three producers fill. The three texts justifying the volume refresh now name the tie-breaks that would otherwise decide a newly queued cell, rather than a tie-break the queue no longer has. The specification states that the root is a member of the investment set by construction and never a competitive target, and the sites asserting it cite the section that now carries it.