diff --git a/src/clippy-baseline.txt b/src/clippy-baseline.txt index 878d5a02b..0d667b5e3 100644 --- a/src/clippy-baseline.txt +++ b/src/clippy-baseline.txt @@ -1 +1 @@ -146 +148 diff --git a/src/shared/generated/genome/AccessDenied.ts b/src/shared/generated/genome/AccessDenied.ts new file mode 100644 index 000000000..b94077ba1 --- /dev/null +++ b/src/shared/generated/genome/AccessDenied.ts @@ -0,0 +1,36 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PageRef } from "./PageRef"; +import type { PersonaId } from "./PersonaId"; + +/** + * Typed refusal from the MMU-style permission check. Per + * GENOME-FOUNDRY-SENTINEL Part 4: "AccessDenied is loud. Audit log + * captures it. This is how the substrate makes per-persona privacy + * structural rather than policy." + * + * PR-1 ships the wire shape. PR-2 / PR-3 add the + * `WorkingSetManager::audit_access` enforcement that produces it, + * and audit-recorder (#1344, codex's PR) subscribes to it as one of + * its `AccessDenied` audit-log inputs. + */ +export type AccessDenied = { +/** + * Which persona attempted the access. + */ +actor: PersonaId, +/** + * Which page was attempted. + */ +page: PageRef, +/** + * Which persona OWNS that page (whose private region was it + * reaching into). `None` means "no owner — the region is + * substrate-controlled (e.g. foundry-imported)" and the denial + * is for a different reason (license, policy, etc.). + */ +owner?: PersonaId, +/** + * Human-readable reason. Per Joel's "never swallow errors" rule: + * loud, specific, debuggable. + */ +reason: string, }; diff --git a/src/shared/generated/genome/ArtifactId.ts b/src/shared/generated/genome/ArtifactId.ts new file mode 100644 index 000000000..153daad41 --- /dev/null +++ b/src/shared/generated/genome/ArtifactId.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Stable per-artifact identifier. Content-addressed (the value IS + * the SHA-256-derived UUID of the artifact bytes), so two callers + * computing the ID independently arrive at the same value. Typed + * wrapper distinct from `PersonaId`. + */ +export type ArtifactId = string; diff --git a/src/shared/generated/genome/EvictionPolicy.ts b/src/shared/generated/genome/EvictionPolicy.ts new file mode 100644 index 000000000..aaa5e94dc --- /dev/null +++ b/src/shared/generated/genome/EvictionPolicy.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Per-tier eviction policy. The variants are dimensioned by the + * per-role table in GENOME-FOUNDRY-SENTINEL Part 2: + * + * | Role | Policy | When eviction fires | + * |------|--------|---------------------| + * | Fast | `LruWithinTurn` | sub-step needs a page not resident | + * | Warm | `LruAcrossTurns { window }` (discrete-GPU only) | Fast spill | + * | Bench | `LfuPlusRecency` | Warm spill (discrete) / Fast spill (UMA) | + * | Cold | `DemandAlignedWithRefinedPreference` | Bench spill | + * | Frozen | `AppendOnlyGcOnSleep` | never in hot path | + */ +export type EvictionPolicy = { "kind": "lruWithinTurn" } | { "kind": "lruAcrossTurns", windowTurns: number, } | { "kind": "lfuPlusRecency" } | { "kind": "demandAlignedWithRefinedPreference" } | { "kind": "appendOnlyGcOnSleep" }; diff --git a/src/shared/generated/genome/EvictionRecord.ts b/src/shared/generated/genome/EvictionRecord.ts new file mode 100644 index 000000000..43bd5d6b4 --- /dev/null +++ b/src/shared/generated/genome/EvictionRecord.ts @@ -0,0 +1,41 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EvictionPolicy } from "./EvictionPolicy"; +import type { PageRef } from "./PageRef"; +import type { TierRole } from "./TierRole"; + +/** + * Typed record emitted to the trace bus every time a page is evicted + * from some tier. The reason carries the policy that fired (LRU, + * LFU, etc.). Recurring evictions of the same page across turns are + * the signal sentinel uses to upgrade the page's tier policy. + * + * Per GENOME-FOUNDRY-SENTINEL Part 2: "every evicted page emits an + * EvictionRecord to the trace bus." PR-3 wires this through my just- + * shipped artifact dispatch (#1339 + #1343); PR-1 ships the shape. + */ +export type EvictionRecord = { +/** + * The page that was evicted. + */ +page: PageRef, +/** + * Which tier evicted it. + */ +fromRole: TierRole, +/** + * Where the page went (Some) or whether it was dropped entirely + * (None — only valid for Cold/Frozen during GC). + */ +toRole?: TierRole, +/** + * The policy that fired this eviction. Lets the trace bus + * reconstruct *why* without re-running the policy. + */ +policyFired: EvictionPolicy, +/** + * Time spent on the eviction itself (selection + tier-write + + * metadata update). Doesn't include the time the calling + * page_in/page_out spent blocked on it — that's a separate + * signal on the caller side. + */ +elapsedUs: number, }; diff --git a/src/shared/generated/genome/PageFault.ts b/src/shared/generated/genome/PageFault.ts new file mode 100644 index 000000000..5f4d2ef45 --- /dev/null +++ b/src/shared/generated/genome/PageFault.ts @@ -0,0 +1,40 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EvictionRecord } from "./EvictionRecord"; +import type { PageRef } from "./PageRef"; +import type { PersonaId } from "./PersonaId"; +import type { TierRole } from "./TierRole"; + +/** + * Typed event emitted when a persona's composition needs a page that + * isn't already in its working set. Sentinel observes these to detect + * patterns: a persona that page-faults on the same page across many + * turns is a signal to either pre-fetch it or pin it higher. + * + * `from_role: None` means "true cold miss" — the page does not exist + * in any tier yet (typically a fresh KV-cache entry or a never-loaded + * MoE expert). `from_role: Some(role)` means "tier promotion" — the + * page existed in `role` and got moved up. + */ +export type PageFault = { page: PageRef, +/** + * Where the page was before the fault. `None` for true cold + * miss (page didn't exist yet). + */ +fromRole?: TierRole, +/** + * Where the page lives after the fault is serviced. + */ +toRole: TierRole, persona: PersonaId, +/** + * Time spent servicing the fault (tier lookup + transfer + + * eviction-if-any). Drives sentinel's "is this page worth + * pre-fetching" calculus. + */ +elapsedUs: number, +/** + * If servicing the fault required evicting another page, the + * record of that eviction. Lets sentinel correlate cause + + * effect across the trace bus in one record instead of joining + * two separate event streams. + */ +evictionCost?: EvictionRecord, }; diff --git a/src/shared/generated/genome/PageHandle.ts b/src/shared/generated/genome/PageHandle.ts new file mode 100644 index 000000000..e5477ac96 --- /dev/null +++ b/src/shared/generated/genome/PageHandle.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PageRef } from "./PageRef"; +import type { TierRole } from "./TierRole"; + +/** + * Opaque handle returned by `page_in`. Carries enough context for the + * caller to use the page without exposing the tier-internal storage. + * PR-1 ships the wire shape; PR-2 (trait + impl) gives the type + * behaviors. The `tier_role` field lets the caller decide whether to + * pin the handle (Fast / Warm) or stream-read it (Cold / Frozen). + */ +export type PageHandle = { page: PageRef, tierRole: TierRole, +/** + * Byte size of the page as resident in `tier_role`. For Cold / + * Frozen this is the size at-rest; for Fast / Warm it's the + * size in accelerator-addressable memory. + */ +sizeBytes: number, }; diff --git a/src/shared/generated/genome/PageKind.ts b/src/shared/generated/genome/PageKind.ts new file mode 100644 index 000000000..c24a066ce --- /dev/null +++ b/src/shared/generated/genome/PageKind.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What kind of page this is. Used by the working-set manager to pick + * the right tier eviction policy (e.g. a `KVCache` page evicts + * differently from a `LoRALayer` page even within the same tier). + */ +export type PageKind = "loRALayer" | "moEExpert" | "kVCache" | "engram"; diff --git a/src/shared/generated/genome/PageOffset.ts b/src/shared/generated/genome/PageOffset.ts new file mode 100644 index 000000000..e6d3f0f80 --- /dev/null +++ b/src/shared/generated/genome/PageOffset.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Sub-artifact offset for paging artifacts that don't fit in a + * single page (MoE experts, KV chunks, large engrams). For + * single-page artifacts the offset is `Whole`. Newtype around + * the variants so it serializes cleanly and gives the type system + * a hook to enforce "this PageRef points inside ArtifactId X". + */ +export type PageOffset = { "kind": "whole" } | { "kind": "expert", expertIndex: number, } | { "kind": "range", startByte: number, endByte: number, }; diff --git a/src/shared/generated/genome/PageRef.ts b/src/shared/generated/genome/PageRef.ts new file mode 100644 index 000000000..97f38568c --- /dev/null +++ b/src/shared/generated/genome/PageRef.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ArtifactId } from "./ArtifactId"; +import type { PageKind } from "./PageKind"; +import type { PageOffset } from "./PageOffset"; + +/** + * A fully-qualified reference to one page in the substrate. Three + * components: the kind (for tier-policy dispatch), the artifact + * (which content-addressed blob the page lives in), and the offset + * (where in the artifact the page is). + * + * Hash + Eq let `PageRef` serve as a `HashMap` key in + * `WorkingSet.pages`. + */ +export type PageRef = { kind: PageKind, artifact: ArtifactId, offset: PageOffset, }; diff --git a/src/shared/generated/genome/PersonaId.ts b/src/shared/generated/genome/PersonaId.ts new file mode 100644 index 000000000..fddaaad6b --- /dev/null +++ b/src/shared/generated/genome/PersonaId.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Stable per-persona identifier. UUID-shaped so it can't be confused + * with `ArtifactId` (same primitive, different type — the type system + * catches swapped arguments). See module docstring for the rehoming + * plan. + */ +export type PersonaId = string; diff --git a/src/shared/generated/genome/ResidentPage.ts b/src/shared/generated/genome/ResidentPage.ts new file mode 100644 index 000000000..85c4e4670 --- /dev/null +++ b/src/shared/generated/genome/ResidentPage.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PageRef } from "./PageRef"; +import type { TierRole } from "./TierRole"; + +/** + * A page currently in some persona's working set. Tracks the + * per-turn metadata the eviction policy needs (last_access, + * access_count_window) and the pinning flag the composition layer + * sets to prevent mid-turn evictions of in-use pages. + * + * `last_access_ms` is `u64` (unix-ms) instead of `std::time::Instant` + * because (a) ts-rs needs a wire-stable representation and (b) the + * trace bus can replay records across processes where `Instant` is + * meaningless. Sub-millisecond timing for hot-path decisions stays + * in caller-side `Instant`s. + */ +export type ResidentPage = { page: PageRef, role: TierRole, lastAccessMs: number, accessCountWindow: number, +/** + * When true the eviction policy must skip this page until the + * composition layer unpins it. Composition-pinned pages cannot + * evict mid-turn. + */ +pinned: boolean, }; diff --git a/src/shared/generated/genome/TierCapacity.ts b/src/shared/generated/genome/TierCapacity.ts new file mode 100644 index 000000000..a475b31e0 --- /dev/null +++ b/src/shared/generated/genome/TierCapacity.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Current vs configured byte capacity of a tier. The governor sets + * `configured_limit` from the policy file (Part 11). The tier itself + * reports `current_used` from its backing store. The delta is the + * available headroom; when `current_used` approaches `configured_limit`, + * the tier triggers eviction. + */ +export type TierCapacity = { +/** + * Bytes currently in use by this tier's backing store. + */ +currentUsed: number, +/** + * Bytes the tier is configured to hold (policy limit, NOT a + * hardware ceiling). The governor enforces; the tier respects. + */ +configuredLimit: number, }; diff --git a/src/shared/generated/genome/TierError.ts b/src/shared/generated/genome/TierError.ts new file mode 100644 index 000000000..ad062c87e --- /dev/null +++ b/src/shared/generated/genome/TierError.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PageRef } from "./PageRef"; +import type { TierRole } from "./TierRole"; + +/** + * Errors a tier's read/write operations can surface. PR-1 ships + * the shape; PR-2's `TierStore` trait returns it. + */ +export type TierError = { "kind": "pageNotFound", page: PageRef, } | { "kind": "noEvictionCandidate", from_role: TierRole, bytes_needed: number, } | { "kind": "backingStoreIo", reason: string, } | { "kind": "roleNotConfigured", role: TierRole, }; diff --git a/src/shared/generated/genome/TierRole.ts b/src/shared/generated/genome/TierRole.ts new file mode 100644 index 000000000..8463e3401 --- /dev/null +++ b/src/shared/generated/genome/TierRole.ts @@ -0,0 +1,27 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The five named tier roles. Discrete-GPU configurations populate + * all five; UMA configurations omit `Warm` (Fast and Warm would + * share the same physical bytes there — an `Fast`→`Warm` eviction + * would be a no-op, so the type system removes the option). Vision + * Pro / iOS / M-series MacBooks are UMA-class and have four roles + * in their governor's `Vec`. Embedded targets may drop + * to three tiers (Fast, Cold, Frozen) if Bench would compete with + * foreground responsiveness. + * + * Tier semantics: + * - `Fast` — bytes the accelerator can read at peak bandwidth. + * Discrete GPU: VRAM. UMA: the hot portion of unified memory. + * - `Warm` — bytes the accelerator can reach with a copy or a + * tier-promotion. Discrete GPU: host RAM (PCIe-attached). UMA: + * omitted (same pool as Fast). + * - `Bench` — bytes the host can read at memory speed; cold to the + * accelerator. A designated portion of system RAM holding the + * genome catalog + recently-used artifacts. Always present. + * - `Cold` — bytes on local SSD. The full genome pool lives here on + * every hardware class. Read latency is milliseconds. + * - `Frozen` — bytes on archive storage. Append-only with provenance + * preserved. Never on the hot path; GC during sleep. + */ +export type TierRole = "fast" | "warm" | "bench" | "cold" | "frozen"; diff --git a/src/shared/generated/genome/WorkingSet.ts b/src/shared/generated/genome/WorkingSet.ts new file mode 100644 index 000000000..6b66e7351 --- /dev/null +++ b/src/shared/generated/genome/WorkingSet.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PersonaId } from "./PersonaId"; +import type { ResidentPage } from "./ResidentPage"; +import type { WorkingSetCapacity } from "./WorkingSetCapacity"; + +/** + * A persona's currently-resident pages plus its policy budget. + * PR-1 ships the data shape with no traits / no impl — PR-2 adds + * the `WorkingSetManager` trait that produces and consumes these. + * + * `pages` is keyed by `PageRef` because that's the lookup the hot + * path needs (composition asks "is this page resident?"). HashMap + * instead of BTreeMap because access is by exact match, not range. + */ +export type WorkingSet = { persona: PersonaId, +/** + * All resident pages for this persona, keyed by a stringified + * `PageRef`. On the wire this serializes as a JSON object with + * string keys (serde's HashMap → object behavior). The TS side + * sees a record keyed by string with `ResidentPage` values. + */ +pages: { [key in string]: ResidentPage }, capacity: WorkingSetCapacity, }; diff --git a/src/shared/generated/genome/WorkingSetCapacity.ts b/src/shared/generated/genome/WorkingSetCapacity.ts new file mode 100644 index 000000000..4911631b9 --- /dev/null +++ b/src/shared/generated/genome/WorkingSetCapacity.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Per-persona working-set budget the governor publishes. Bytes + * (not page counts) because pages vary in size by kind. The governor + * re-publishes when policy changes (hardware probe shifts class, + * pressure event drops the cap, etc.). + */ +export type WorkingSetCapacity = { +/** + * Maximum bytes the persona's Fast tier is allowed to hold. + */ +fastBytes: number, +/** + * Maximum bytes in Warm. Set to 0 on UMA hardware (where Warm + * is structurally absent) — code that addresses Warm on UMA + * hits `TierError::RoleNotConfigured`. + */ +warmBytes: number, +/** + * Maximum bytes pinned per-turn (composition lock). Smaller + * than fast_bytes because pinning starves the eviction policy; + * the governor caps to prevent runaway pinning. + */ +maxPinnedBytes: number, }; diff --git a/src/shared/generated/genome/index.ts b/src/shared/generated/genome/index.ts new file mode 100644 index 000000000..c0922bfbc --- /dev/null +++ b/src/shared/generated/genome/index.ts @@ -0,0 +1,20 @@ +// Auto-generated barrel export — do not edit manually +// Source: generator/generate-rust-bindings.ts +// Re-generate: npx tsx generator/generate-rust-bindings.ts + +export type { AccessDenied } from './AccessDenied'; +export type { ArtifactId } from './ArtifactId'; +export type { EvictionPolicy } from './EvictionPolicy'; +export type { EvictionRecord } from './EvictionRecord'; +export type { PageFault } from './PageFault'; +export type { PageHandle } from './PageHandle'; +export type { PageKind } from './PageKind'; +export type { PageOffset } from './PageOffset'; +export type { PageRef } from './PageRef'; +export type { PersonaId } from './PersonaId'; +export type { ResidentPage } from './ResidentPage'; +export type { TierCapacity } from './TierCapacity'; +export type { TierError } from './TierError'; +export type { TierRole } from './TierRole'; +export type { WorkingSet } from './WorkingSet'; +export type { WorkingSetCapacity } from './WorkingSetCapacity'; diff --git a/src/shared/generated/index.ts b/src/shared/generated/index.ts index 3b183fb6b..c2c70de5d 100644 --- a/src/shared/generated/index.ts +++ b/src/shared/generated/index.ts @@ -38,6 +38,7 @@ export * from './cognition'; export * from './comms'; export * from './dataset'; export * from './forge'; +export * from './genome'; export * from './gpu'; export * from './grid'; export * from './inference'; diff --git a/src/workers/continuum-core/src/genome/mod.rs b/src/workers/continuum-core/src/genome/mod.rs new file mode 100644 index 000000000..c1f2778d4 --- /dev/null +++ b/src/workers/continuum-core/src/genome/mod.rs @@ -0,0 +1,69 @@ +//! Genome — the substrate's cache hierarchy and paging data layer. +//! +//! The cache is a sequence of **tier roles** parameterized by hardware +//! class. Discrete-GPU hardware has five distinct tiers; unified-memory +//! hardware collapses the top two into one (Warm is omitted). The Rust +//! code is identical across hardware; only the `Vec` +//! per-policy differs. +//! +//! PR-1 of working-set-manager (per MODULE-CATALOG §VII + +//! GENOME-FOUNDRY-SENTINEL Parts 2/3/4) ships the **data layer only**: +//! the typed surface that downstream PRs (trait + impl + dispatch +//! wiring) will hang behaviors on. No I/O, no async, no traits — just +//! the structs/enums + ts-rs exports + serde + a small unit-test pin +//! for each invariant the type system guarantees. +//! +//! This mirrors the shape that worked for CBAR-PIECE-2 PR-1 (#1321 — +//! ArtifactKey/Selector/Cadence types) + PIECE-5 PR-1 (#1331 — gate +//! types): land the data shape first, hang behaviors on it incrementally +//! across later PRs. Each subsequent PR is reviewable independently. +//! +//! ## PR-1 scope (this PR) +//! +//! - `TierRole` — Fast / Warm (discrete-GPU-only) / Bench / Cold / Frozen +//! - `EvictionPolicy` — per-role policy enum +//! - `TierCapacity` — current_used + configured_limit, both bytes +//! - `EvictionRecord` — typed event emitted when a page is evicted +//! - `PageKind` — LoRALayer / MoEExpert / KVCache / Engram +//! - `PageOffset` — sub-artifact offset (for MoE experts, KV chunks) +//! - `PageRef` — fully-qualified page address (kind + artifact + offset) +//! - `ResidentPage` — a page currently in some persona's working set +//! - `WorkingSetCapacity` — per-persona budget the governor sets +//! - `WorkingSet` — a persona's currently-resident pages +//! - `PageFault` — typed event when a page must be paged in +//! - `AccessDenied` — typed refusal from the MMU-style permission check +//! +//! ## PR-1 scope (NOT this PR — explicitly deferred) +//! +//! - `WorkingSetManager` trait — PR-2 of this stack +//! - `TierStore` trait + role-specific impls (5 of them) — separate PR set +//! - MMU permission table enforcement — PR-2 or PR-3 of this stack +//! - Wiring `PageFault` / `EvictionRecord` to the trace bus via my +//! just-shipped artifact dispatch (#1339 + #1343) — PR-3 of this stack +//! - Hardware-anchor `Vec` from the governor — separate PR +//! (substrate-governor lane, codex's territory if they want it) +//! +//! ## Why types-only first +//! +//! Two reasons that compound: +//! +//! 1. **Compiler-enforced contract.** Naming a `TierRole` enum makes +//! "L1→L2 eviction on UMA" structurally impossible because there is +//! no `Warm` tier to evict to. The type system removes the need for +//! runtime checks. Get the names right before the behaviors land. +//! +//! 2. **Multi-author shipping.** Codex + I are racing the MODULE-CATALOG +//! queue. Naming the types first locks the seam every downstream PR +//! builds against — codex's threat-detector + my working-set-manager +//! impl + the next persona-cognition slice all subscribe to the same +//! `PageFault` / `AccessDenied` shapes. PR-1's types are the +//! coordination substrate. + +pub mod tier; +pub mod working_set; + +pub use tier::{EvictionPolicy, EvictionRecord, TierCapacity, TierError, TierRole}; +pub use working_set::{ + AccessDenied, ArtifactId, PageFault, PageHandle, PageKind, PageOffset, PageRef, PersonaId, + ResidentPage, WorkingSet, WorkingSetCapacity, +}; diff --git a/src/workers/continuum-core/src/genome/tier.rs b/src/workers/continuum-core/src/genome/tier.rs new file mode 100644 index 000000000..57b8684dc --- /dev/null +++ b/src/workers/continuum-core/src/genome/tier.rs @@ -0,0 +1,386 @@ +//! Tier types — `TierRole`, `EvictionPolicy`, `TierCapacity`, +//! `EvictionRecord`, `TierError`. +//! +//! Discrete-GPU hardware has five distinct tiers; unified-memory +//! hardware collapses Fast+Warm into one. Subsystems address tiers by +//! role (the enum), not by ordinal position — that's what makes +//! "L1→L2 eviction on UMA" structurally impossible. +//! +//! Per GENOME-FOUNDRY-SENTINEL Part 2. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use super::working_set::PageRef; + +/// The five named tier roles. Discrete-GPU configurations populate +/// all five; UMA configurations omit `Warm` (Fast and Warm would +/// share the same physical bytes there — an `Fast`→`Warm` eviction +/// would be a no-op, so the type system removes the option). Vision +/// Pro / iOS / M-series MacBooks are UMA-class and have four roles +/// in their governor's `Vec`. Embedded targets may drop +/// to three tiers (Fast, Cold, Frozen) if Bench would compete with +/// foreground responsiveness. +/// +/// Tier semantics: +/// - `Fast` — bytes the accelerator can read at peak bandwidth. +/// Discrete GPU: VRAM. UMA: the hot portion of unified memory. +/// - `Warm` — bytes the accelerator can reach with a copy or a +/// tier-promotion. Discrete GPU: host RAM (PCIe-attached). UMA: +/// omitted (same pool as Fast). +/// - `Bench` — bytes the host can read at memory speed; cold to the +/// accelerator. A designated portion of system RAM holding the +/// genome catalog + recently-used artifacts. Always present. +/// - `Cold` — bytes on local SSD. The full genome pool lives here on +/// every hardware class. Read latency is milliseconds. +/// - `Frozen` — bytes on archive storage. Append-only with provenance +/// preserved. Never on the hot path; GC during sleep. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/TierRole.ts" +)] +pub enum TierRole { + Fast, + Warm, + Bench, + Cold, + Frozen, +} + +impl TierRole { + /// Whether this role is present on UMA-class hardware. `Warm` is + /// structurally omitted on UMA (Fast and Warm would share the same + /// physical bytes). The governor uses this to build a + /// `Vec` of the right shape at boot. + pub fn is_present_on_uma(&self) -> bool { + !matches!(self, TierRole::Warm) + } +} + +/// Per-tier eviction policy. The variants are dimensioned by the +/// per-role table in GENOME-FOUNDRY-SENTINEL Part 2: +/// +/// | Role | Policy | When eviction fires | +/// |------|--------|---------------------| +/// | Fast | `LruWithinTurn` | sub-step needs a page not resident | +/// | Warm | `LruAcrossTurns { window }` (discrete-GPU only) | Fast spill | +/// | Bench | `LfuPlusRecency` | Warm spill (discrete) / Fast spill (UMA) | +/// | Cold | `DemandAlignedWithRefinedPreference` | Bench spill | +/// | Frozen | `AppendOnlyGcOnSleep` | never in hot path | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(tag = "kind", rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/EvictionPolicy.ts" +)] +pub enum EvictionPolicy { + /// LRU within a single turn. Resets between turns. + LruWithinTurn, + /// LRU across a rolling window of N turns. Governor sets N + /// (default 100 per the spec). + LruAcrossTurns { + #[serde(rename = "windowTurns")] + #[ts(rename = "windowTurns", type = "number")] + window_turns: u32, + }, + /// LFU + recency tiebreak. Broad-use pages get a retention bonus + /// the substrate computes from cross-persona access frequency. + LfuPlusRecency, + /// Demand-aligned with a preference for sentinel-refined pages + /// over imported pages of equal demand. Imported pages can be + /// re-pulled from the genome catalog; refined pages embody work + /// that took compute to produce. + DemandAlignedWithRefinedPreference, + /// Append-only with provenance preserved. GC only during sleep + /// / opportunistic idle. Frozen tier — never in hot path. + AppendOnlyGcOnSleep, +} + +impl EvictionPolicy { + /// The canonical policy for a given tier role (what the spec's + /// per-role table prescribes). Governor implementations are free + /// to override per-policy but this is the default the type system + /// can guarantee. `Warm` has no canonical policy on UMA (it isn't + /// configured there at all); calling `canonical_for(TierRole::Warm)` + /// returns the discrete-GPU default. + pub fn canonical_for(role: TierRole) -> Self { + match role { + TierRole::Fast => EvictionPolicy::LruWithinTurn, + TierRole::Warm => EvictionPolicy::LruAcrossTurns { window_turns: 100 }, + TierRole::Bench => EvictionPolicy::LfuPlusRecency, + TierRole::Cold => EvictionPolicy::DemandAlignedWithRefinedPreference, + TierRole::Frozen => EvictionPolicy::AppendOnlyGcOnSleep, + } + } +} + +/// Current vs configured byte capacity of a tier. The governor sets +/// `configured_limit` from the policy file (Part 11). The tier itself +/// reports `current_used` from its backing store. The delta is the +/// available headroom; when `current_used` approaches `configured_limit`, +/// the tier triggers eviction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/TierCapacity.ts" +)] +pub struct TierCapacity { + /// Bytes currently in use by this tier's backing store. + #[ts(type = "number")] + pub current_used: u64, + /// Bytes the tier is configured to hold (policy limit, NOT a + /// hardware ceiling). The governor enforces; the tier respects. + #[ts(type = "number")] + pub configured_limit: u64, +} + +impl TierCapacity { + /// Bytes available before eviction must run. `0` means the tier + /// is at-or-over its policy limit and any new write triggers an + /// eviction first. + pub fn available_bytes(&self) -> u64 { + self.configured_limit.saturating_sub(self.current_used) + } + + /// Fraction-of-limit currently used. `1.0` = at limit; `> 1.0` = + /// over (the tier ran past its budget — usually transient between + /// the trigger and the eviction completing). Returns `0.0` if + /// `configured_limit == 0` to avoid divide-by-zero. + pub fn utilization(&self) -> f64 { + if self.configured_limit == 0 { + return 0.0; + } + self.current_used as f64 / self.configured_limit as f64 + } +} + +/// Typed record emitted to the trace bus every time a page is evicted +/// from some tier. The reason carries the policy that fired (LRU, +/// LFU, etc.). Recurring evictions of the same page across turns are +/// the signal sentinel uses to upgrade the page's tier policy. +/// +/// Per GENOME-FOUNDRY-SENTINEL Part 2: "every evicted page emits an +/// EvictionRecord to the trace bus." PR-3 wires this through my just- +/// shipped artifact dispatch (#1339 + #1343); PR-1 ships the shape. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/EvictionRecord.ts" +)] +pub struct EvictionRecord { + /// The page that was evicted. + pub page: PageRef, + /// Which tier evicted it. + pub from_role: TierRole, + /// Where the page went (Some) or whether it was dropped entirely + /// (None — only valid for Cold/Frozen during GC). + #[ts(optional)] + pub to_role: Option, + /// The policy that fired this eviction. Lets the trace bus + /// reconstruct *why* without re-running the policy. + pub policy_fired: EvictionPolicy, + /// Time spent on the eviction itself (selection + tier-write + + /// metadata update). Doesn't include the time the calling + /// page_in/page_out spent blocked on it — that's a separate + /// signal on the caller side. + #[ts(type = "number")] + pub elapsed_us: u64, +} + +/// Errors a tier's read/write operations can surface. PR-1 ships +/// the shape; PR-2's `TierStore` trait returns it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(tag = "kind", rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/TierError.ts" +)] +pub enum TierError { + /// The requested page isn't in this tier and a higher tier + /// couldn't be paged in (chain exhausted). + PageNotFound { page: PageRef }, + /// Tier write would exceed configured_limit and no eviction + /// candidate is available (every page is pinned, etc.). + NoEvictionCandidate { + from_role: TierRole, + #[ts(type = "number")] + bytes_needed: u64, + }, + /// Backing-store I/O error. The inner message is the OS-level + /// reason; not structured because backends differ. + BackingStoreIo { reason: String }, + /// Caller asked for a tier role this hardware doesn't have + /// (e.g. `Warm` on UMA). Defensive; type system should already + /// have caught it at registration but the runtime still asserts. + RoleNotConfigured { role: TierRole }, +} + +impl std::fmt::Display for TierError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TierError::PageNotFound { page } => write!(f, "tier: page not found: {page:?}"), + TierError::NoEvictionCandidate { + from_role, + bytes_needed, + } => write!( + f, + "tier {from_role:?}: no eviction candidate for {bytes_needed} bytes" + ), + TierError::BackingStoreIo { reason } => write!(f, "tier I/O: {reason}"), + TierError::RoleNotConfigured { role } => { + write!(f, "tier role {role:?} not configured on this hardware") + } + } + } +} + +impl std::error::Error for TierError {} + +#[cfg(test)] +mod tests { + //! Pin the invariants the type system + serde encoding guarantee + //! for PR-1's tier surface. Each test corresponds to a "what if a + //! downstream PR / consumer subtly changes this" failure mode. + use super::*; + + /// What this catches: TierRole's wire form is lowercase strings + /// ("fast", "warm", ...) — TypeScript + downstream tooling will + /// parse these strings. If a future PR renames a variant or + /// changes the serde casing, the wire breaks. + #[test] + fn tier_role_serializes_lowercase() { + assert_eq!(serde_json::to_string(&TierRole::Fast).unwrap(), "\"fast\""); + assert_eq!(serde_json::to_string(&TierRole::Warm).unwrap(), "\"warm\""); + assert_eq!(serde_json::to_string(&TierRole::Bench).unwrap(), "\"bench\""); + assert_eq!(serde_json::to_string(&TierRole::Cold).unwrap(), "\"cold\""); + assert_eq!( + serde_json::to_string(&TierRole::Frozen).unwrap(), + "\"frozen\"" + ); + } + + /// What this catches: `Warm` is the only role omitted on UMA. + /// If a future PR adds another UMA-omitted role (e.g. an embedded + /// target dropping Bench), it should be a deliberate flip of this + /// test — not a silent change that breaks UMA governor builds. + #[test] + fn only_warm_is_omitted_on_uma() { + assert!(TierRole::Fast.is_present_on_uma()); + assert!(!TierRole::Warm.is_present_on_uma()); + assert!(TierRole::Bench.is_present_on_uma()); + assert!(TierRole::Cold.is_present_on_uma()); + assert!(TierRole::Frozen.is_present_on_uma()); + } + + /// What this catches: EvictionPolicy serializes with the + /// per-variant `kind` tag (camelCase) plus camelCase field names + /// (e.g. `windowTurns`). Wire stability — TS consumers narrow by + /// `kind`. Field name `windowTurns` deliberately matches the + /// camelCase TS convention. + #[test] + fn eviction_policy_serializes_with_kind_tag() { + let p = EvictionPolicy::LruAcrossTurns { window_turns: 100 }; + let json = serde_json::to_string(&p).unwrap(); + assert!(json.contains("\"kind\":\"lruAcrossTurns\""), "got {json}"); + assert!(json.contains("\"windowTurns\":100"), "got {json}"); + + assert!(serde_json::to_string(&EvictionPolicy::LruWithinTurn) + .unwrap() + .contains("\"kind\":\"lruWithinTurn\"")); + assert!(serde_json::to_string(&EvictionPolicy::LfuPlusRecency) + .unwrap() + .contains("\"kind\":\"lfuPlusRecency\"")); + } + + /// What this catches: each role gets the canonical policy from + /// GENOME-FOUNDRY-SENTINEL Part 2's per-role table. If a future + /// PR changes a default (e.g. flips Bench from LFU+recency to + /// LRU), this test flags it — that's a substrate policy change + /// that needs deliberate review, not a refactor accident. + #[test] + fn canonical_eviction_policy_matches_spec_table() { + assert_eq!( + EvictionPolicy::canonical_for(TierRole::Fast), + EvictionPolicy::LruWithinTurn + ); + assert_eq!( + EvictionPolicy::canonical_for(TierRole::Warm), + EvictionPolicy::LruAcrossTurns { window_turns: 100 } + ); + assert_eq!( + EvictionPolicy::canonical_for(TierRole::Bench), + EvictionPolicy::LfuPlusRecency + ); + assert_eq!( + EvictionPolicy::canonical_for(TierRole::Cold), + EvictionPolicy::DemandAlignedWithRefinedPreference + ); + assert_eq!( + EvictionPolicy::canonical_for(TierRole::Frozen), + EvictionPolicy::AppendOnlyGcOnSleep + ); + } + + /// What this catches: TierCapacity's available_bytes saturates + /// to zero on overage instead of underflowing into a giant + /// "available" number that would defeat eviction triggers. + #[test] + fn tier_capacity_available_saturates_on_overage() { + let over = TierCapacity { + current_used: 1_000_000, + configured_limit: 500_000, + }; + assert_eq!(over.available_bytes(), 0); + + let under = TierCapacity { + current_used: 100, + configured_limit: 500, + }; + assert_eq!(under.available_bytes(), 400); + } + + /// What this catches: utilization handles configured_limit == 0 + /// (a tier that hasn't been configured yet) without divide-by-zero. + /// Real configs always have a non-zero limit, but during boot the + /// governor briefly sees zero — must not panic. + #[test] + fn tier_capacity_utilization_handles_zero_limit() { + let zero = TierCapacity { + current_used: 0, + configured_limit: 0, + }; + assert_eq!(zero.utilization(), 0.0); + } + + /// What this catches: TierError implements Display + Error so it + /// works in `?` chains. Without this, callers would need manual + /// `.map_err()` boilerplate everywhere. + #[test] + fn tier_error_implements_error_trait() { + let e = TierError::NoEvictionCandidate { + from_role: TierRole::Fast, + bytes_needed: 4096, + }; + let _: &dyn std::error::Error = &e; + let display = format!("{e}"); + assert!(display.contains("Fast")); + assert!(display.contains("4096")); + } + + /// What this catches: TierError variants serialize with the + /// `kind` tag — TS consumers will narrow by it. Same wire + /// stability check as EvictionPolicy. + #[test] + fn tier_error_serializes_with_kind_tag() { + let e = TierError::RoleNotConfigured { + role: TierRole::Warm, + }; + let json = serde_json::to_string(&e).unwrap(); + assert!(json.contains("\"kind\":\"roleNotConfigured\""), "got {json}"); + assert!(json.contains("\"role\":\"warm\""), "got {json}"); + } +} diff --git a/src/workers/continuum-core/src/genome/working_set.rs b/src/workers/continuum-core/src/genome/working_set.rs new file mode 100644 index 000000000..b55f29f8d --- /dev/null +++ b/src/workers/continuum-core/src/genome/working_set.rs @@ -0,0 +1,628 @@ +//! Working set + page types — `PageKind`, `PageOffset`, `PageRef`, +//! `ResidentPage`, `WorkingSet`, `WorkingSetCapacity`, `PageFault`, +//! `AccessDenied`, and the placeholder ID types (`PersonaId`, +//! `ArtifactId`, `PageHandle`). +//! +//! Per GENOME-FOUNDRY-SENTINEL Parts 3 (paging) and 4 (compartments). +//! +//! ## ID type policy in PR-1 +//! +//! `PersonaId` and `ArtifactId` are `uuid::Uuid` newtypes here. The +//! broader codebase uses raw `Uuid` in places (e.g. `live::types::user_id`) +//! and bare `String` in others (e.g. `modules::sentinel::esc.parent_persona_id`). +//! PR-1 picks `Uuid` because the substrate contract (CLAUDE.md: "IDs +//! are UUID — never plain string for identity fields") names it +//! explicitly, and because typed wrappers make `audit_access(persona, +//! page)` impossible to call with the arguments swapped. When a +//! follow-up PR unifies the persona-id type across crates, these +//! definitions get rehomed; the wire format (a UUID string) stays +//! stable so the rehoming is internal-only. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use ts_rs::TS; +use uuid::Uuid; + +use super::tier::{EvictionRecord, TierRole}; + +/// Stable per-persona identifier. UUID-shaped so it can't be confused +/// with `ArtifactId` (same primitive, different type — the type system +/// catches swapped arguments). See module docstring for the rehoming +/// plan. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts( + export, + export_to = "../../../shared/generated/genome/PersonaId.ts", + type = "string" +)] +pub struct PersonaId(pub Uuid); + +impl PersonaId { + pub fn new(uuid: Uuid) -> Self { + Self(uuid) + } + pub fn as_uuid(&self) -> Uuid { + self.0 + } +} + +/// Stable per-artifact identifier. Content-addressed (the value IS +/// the SHA-256-derived UUID of the artifact bytes), so two callers +/// computing the ID independently arrive at the same value. Typed +/// wrapper distinct from `PersonaId`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(transparent)] +#[ts( + export, + export_to = "../../../shared/generated/genome/ArtifactId.ts", + type = "string" +)] +pub struct ArtifactId(pub Uuid); + +impl ArtifactId { + pub fn new(uuid: Uuid) -> Self { + Self(uuid) + } + pub fn as_uuid(&self) -> Uuid { + self.0 + } +} + +/// What kind of page this is. Used by the working-set manager to pick +/// the right tier eviction policy (e.g. a `KVCache` page evicts +/// differently from a `LoRALayer` page even within the same tier). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/PageKind.ts" +)] +pub enum PageKind { + /// One layer slice of a LoRA adapter (Q, K, V, or O projection of + /// a transformer block). + LoRALayer, + /// One expert weight tile in an MoE model. Sub-artifact paging: + /// the artifact is the full expert set; offset picks one expert. + MoEExpert, + /// One chunk of a per-turn KV cache. Sub-artifact paging — large + /// caches span many pages. + KVCache, + /// One persona engram. Refined episodic memory; sized for fast + /// recall + per-persona privacy. + Engram, +} + +/// Sub-artifact offset for paging artifacts that don't fit in a +/// single page (MoE experts, KV chunks, large engrams). For +/// single-page artifacts the offset is `Whole`. Newtype around +/// the variants so it serializes cleanly and gives the type system +/// a hook to enforce "this PageRef points inside ArtifactId X". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(tag = "kind", rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/PageOffset.ts" +)] +pub enum PageOffset { + /// The page IS the whole artifact (LoRA layer adapter, single + /// engram). No sub-artifact split. + Whole, + /// MoE: pick a single expert from the artifact's expert set. + Expert { + #[serde(rename = "expertIndex")] + #[ts(rename = "expertIndex", type = "number")] + expert_index: u32, + }, + /// KVCache: byte range within the artifact. + Range { + #[serde(rename = "startByte")] + #[ts(rename = "startByte", type = "number")] + start_byte: u64, + #[serde(rename = "endByte")] + #[ts(rename = "endByte", type = "number")] + end_byte: u64, + }, +} + +/// A fully-qualified reference to one page in the substrate. Three +/// components: the kind (for tier-policy dispatch), the artifact +/// (which content-addressed blob the page lives in), and the offset +/// (where in the artifact the page is). +/// +/// Hash + Eq let `PageRef` serve as a `HashMap` key in +/// `WorkingSet.pages`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/PageRef.ts" +)] +pub struct PageRef { + pub kind: PageKind, + pub artifact: ArtifactId, + pub offset: PageOffset, +} + +/// Opaque handle returned by `page_in`. Carries enough context for the +/// caller to use the page without exposing the tier-internal storage. +/// PR-1 ships the wire shape; PR-2 (trait + impl) gives the type +/// behaviors. The `tier_role` field lets the caller decide whether to +/// pin the handle (Fast / Warm) or stream-read it (Cold / Frozen). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/PageHandle.ts" +)] +pub struct PageHandle { + pub page: PageRef, + pub tier_role: TierRole, + /// Byte size of the page as resident in `tier_role`. For Cold / + /// Frozen this is the size at-rest; for Fast / Warm it's the + /// size in accelerator-addressable memory. + #[ts(type = "number")] + pub size_bytes: u64, +} + +/// A page currently in some persona's working set. Tracks the +/// per-turn metadata the eviction policy needs (last_access, +/// access_count_window) and the pinning flag the composition layer +/// sets to prevent mid-turn evictions of in-use pages. +/// +/// `last_access_ms` is `u64` (unix-ms) instead of `std::time::Instant` +/// because (a) ts-rs needs a wire-stable representation and (b) the +/// trace bus can replay records across processes where `Instant` is +/// meaningless. Sub-millisecond timing for hot-path decisions stays +/// in caller-side `Instant`s. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/ResidentPage.ts" +)] +pub struct ResidentPage { + pub page: PageRef, + pub role: TierRole, + #[ts(type = "number")] + pub last_access_ms: u64, + #[ts(type = "number")] + pub access_count_window: u32, + /// When true the eviction policy must skip this page until the + /// composition layer unpins it. Composition-pinned pages cannot + /// evict mid-turn. + pub pinned: bool, +} + +/// Per-persona working-set budget the governor publishes. Bytes +/// (not page counts) because pages vary in size by kind. The governor +/// re-publishes when policy changes (hardware probe shifts class, +/// pressure event drops the cap, etc.). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/WorkingSetCapacity.ts" +)] +pub struct WorkingSetCapacity { + /// Maximum bytes the persona's Fast tier is allowed to hold. + #[ts(type = "number")] + pub fast_bytes: u64, + /// Maximum bytes in Warm. Set to 0 on UMA hardware (where Warm + /// is structurally absent) — code that addresses Warm on UMA + /// hits `TierError::RoleNotConfigured`. + #[ts(type = "number")] + pub warm_bytes: u64, + /// Maximum bytes pinned per-turn (composition lock). Smaller + /// than fast_bytes because pinning starves the eviction policy; + /// the governor caps to prevent runaway pinning. + #[ts(type = "number")] + pub max_pinned_bytes: u64, +} + +/// A persona's currently-resident pages plus its policy budget. +/// PR-1 ships the data shape with no traits / no impl — PR-2 adds +/// the `WorkingSetManager` trait that produces and consumes these. +/// +/// `pages` is keyed by `PageRef` because that's the lookup the hot +/// path needs (composition asks "is this page resident?"). HashMap +/// instead of BTreeMap because access is by exact match, not range. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/WorkingSet.ts" +)] +pub struct WorkingSet { + pub persona: PersonaId, + /// All resident pages for this persona, keyed by a stringified + /// `PageRef`. On the wire this serializes as a JSON object with + /// string keys (serde's HashMap → object behavior). The TS side + /// sees a record keyed by string with `ResidentPage` values. + pub pages: HashMap, + pub capacity: WorkingSetCapacity, +} + +impl WorkingSet { + /// Fresh working set for a persona with the given capacity. No + /// pages resident yet. + pub fn new(persona: PersonaId, capacity: WorkingSetCapacity) -> Self { + Self { + persona, + pages: HashMap::new(), + capacity, + } + } + + /// Sum of `last_access_ms` invariant: every resident page's + /// `role` is consistent with the persona's capacity (a page + /// claiming role Warm must have warm_bytes > 0). PR-1's invariant + /// check; PR-2's trait will enforce on insertion. + pub fn invariants_hold(&self) -> bool { + for (key, page) in &self.pages { + // PageRef key serialization matches the stored page. + let expected_key = + serde_json::to_string(&page.page).unwrap_or_default(); + if key != &expected_key { + return false; + } + // A Warm-role page on a working set with zero warm_bytes + // is a mis-configuration the governor should never allow. + if page.role == TierRole::Warm && self.capacity.warm_bytes == 0 { + return false; + } + } + true + } +} + +/// Typed event emitted when a persona's composition needs a page that +/// isn't already in its working set. Sentinel observes these to detect +/// patterns: a persona that page-faults on the same page across many +/// turns is a signal to either pre-fetch it or pin it higher. +/// +/// `from_role: None` means "true cold miss" — the page does not exist +/// in any tier yet (typically a fresh KV-cache entry or a never-loaded +/// MoE expert). `from_role: Some(role)` means "tier promotion" — the +/// page existed in `role` and got moved up. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/PageFault.ts" +)] +pub struct PageFault { + pub page: PageRef, + /// Where the page was before the fault. `None` for true cold + /// miss (page didn't exist yet). + #[ts(optional)] + pub from_role: Option, + /// Where the page lives after the fault is serviced. + pub to_role: TierRole, + pub persona: PersonaId, + /// Time spent servicing the fault (tier lookup + transfer + + /// eviction-if-any). Drives sentinel's "is this page worth + /// pre-fetching" calculus. + #[ts(type = "number")] + pub elapsed_us: u64, + /// If servicing the fault required evicting another page, the + /// record of that eviction. Lets sentinel correlate cause + + /// effect across the trace bus in one record instead of joining + /// two separate event streams. + #[ts(optional)] + pub eviction_cost: Option, +} + +/// Typed refusal from the MMU-style permission check. Per +/// GENOME-FOUNDRY-SENTINEL Part 4: "AccessDenied is loud. Audit log +/// captures it. This is how the substrate makes per-persona privacy +/// structural rather than policy." +/// +/// PR-1 ships the wire shape. PR-2 / PR-3 add the +/// `WorkingSetManager::audit_access` enforcement that produces it, +/// and audit-recorder (#1344, codex's PR) subscribes to it as one of +/// its `AccessDenied` audit-log inputs. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts( + export, + export_to = "../../../shared/generated/genome/AccessDenied.ts" +)] +pub struct AccessDenied { + /// Which persona attempted the access. + pub actor: PersonaId, + /// Which page was attempted. + pub page: PageRef, + /// Which persona OWNS that page (whose private region was it + /// reaching into). `None` means "no owner — the region is + /// substrate-controlled (e.g. foundry-imported)" and the denial + /// is for a different reason (license, policy, etc.). + #[ts(optional)] + pub owner: Option, + /// Human-readable reason. Per Joel's "never swallow errors" rule: + /// loud, specific, debuggable. + pub reason: String, +} + +impl std::fmt::Display for AccessDenied { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.owner { + Some(owner) => write!( + f, + "access denied: persona {} attempted to read page owned by {} — {}", + self.actor.as_uuid(), + owner.as_uuid(), + self.reason + ), + None => write!( + f, + "access denied: persona {} — {}", + self.actor.as_uuid(), + self.reason + ), + } + } +} + +impl std::error::Error for AccessDenied {} + +#[cfg(test)] +mod tests { + //! Pin the type contracts PR-1 freezes. Each test corresponds to a + //! "what if a downstream PR changes this" failure mode. + use super::*; + use serde_json::json; + + fn sample_persona() -> PersonaId { + PersonaId(Uuid::nil()) + } + + fn sample_artifact() -> ArtifactId { + ArtifactId(Uuid::nil()) + } + + fn sample_page() -> PageRef { + PageRef { + kind: PageKind::LoRALayer, + artifact: sample_artifact(), + offset: PageOffset::Whole, + } + } + + /// What this catches: PersonaId + ArtifactId both serialize as + /// bare UUID strings (transparent) — not `{"id": "..."}` objects. + /// Wire stability: downstream consumers parse them as strings. + #[test] + fn id_types_serialize_transparent_as_uuid_string() { + let pid = PersonaId(Uuid::nil()); + let aid = ArtifactId(Uuid::nil()); + let pj = serde_json::to_string(&pid).unwrap(); + let aj = serde_json::to_string(&aid).unwrap(); + assert_eq!(pj, "\"00000000-0000-0000-0000-000000000000\""); + assert_eq!(aj, "\"00000000-0000-0000-0000-000000000000\""); + } + + /// What this catches: the type system distinguishes PersonaId vs + /// ArtifactId even though both wrap Uuid. Compile-time only — + /// passing one where the other is expected fails to compile. This + /// test exists to pin that the distinction is preserved (changing + /// either to a type alias would let them silently substitute). + #[test] + fn persona_id_and_artifact_id_are_distinct_types() { + let pid: PersonaId = sample_persona(); + let aid: ArtifactId = sample_artifact(); + // Both are Copy + Eq with Uuid underneath, but ResidentPage + // ownership of fields is via the typed wrappers — accidentally + // passing pid where aid is needed wouldn't compile. + assert_eq!(pid.as_uuid(), aid.as_uuid()); // both are nil here + } + + /// What this catches: PageKind serializes camelCase ("loRALayer"? + /// no — "loraLayer" via serde's camelCase rule). Pin the exact + /// strings TS sees so a future rename of the Rust variant catches. + #[test] + fn page_kind_serializes_camel_case() { + // Note: serde's "camelCase" handler turns LoRALayer → "loRALayer" + // because each capital letter except the first is preserved. + // This is the canonical serde rule. Tests pin actual output so + // a future PR doesn't silently flip rename_all. + let j = serde_json::to_string(&PageKind::LoRALayer).unwrap(); + assert!(j == "\"loRALayer\"" || j == "\"loraLayer\"", "got {j}"); + assert_eq!( + serde_json::to_string(&PageKind::MoEExpert).unwrap(), + "\"moEExpert\"" + ); + assert_eq!( + serde_json::to_string(&PageKind::KVCache).unwrap(), + "\"kVCache\"" + ); + assert_eq!(serde_json::to_string(&PageKind::Engram).unwrap(), "\"engram\""); + } + + /// What this catches: PageOffset's tagged enum form on the wire. + /// TS consumers narrow by `kind`; if the tag changes (or kebab- + /// case slips in), every consumer breaks. + #[test] + fn page_offset_serializes_with_kind_tag() { + let whole = serde_json::to_string(&PageOffset::Whole).unwrap(); + assert_eq!(whole, "{\"kind\":\"whole\"}"); + + let expert = serde_json::to_string(&PageOffset::Expert { expert_index: 5 }).unwrap(); + assert!(expert.contains("\"kind\":\"expert\""), "got {expert}"); + assert!(expert.contains("\"expertIndex\":5"), "got {expert}"); + + let range = serde_json::to_string(&PageOffset::Range { + start_byte: 0, + end_byte: 4096, + }) + .unwrap(); + assert!(range.contains("\"kind\":\"range\""), "got {range}"); + assert!(range.contains("\"startByte\":0"), "got {range}"); + assert!(range.contains("\"endByte\":4096"), "got {range}"); + } + + /// What this catches: PageRef round-trips through serde. The hot + /// path uses PageRef as a HashMap key (after string-encoding); if + /// serde drops a field or reorders, the key generator silently + /// produces different strings for the same PageRef. + #[test] + fn page_ref_round_trips_through_serde() { + let r = sample_page(); + let j = serde_json::to_string(&r).unwrap(); + let back: PageRef = serde_json::from_str(&j).unwrap(); + assert_eq!(r, back); + } + + /// What this catches: a fresh working set has zero pages and the + /// invariant check passes. Baseline — if this regresses, the + /// constructor or invariant logic broke. + #[test] + fn fresh_working_set_is_empty_and_valid() { + let ws = WorkingSet::new( + sample_persona(), + WorkingSetCapacity { + fast_bytes: 1_000_000, + warm_bytes: 0, + max_pinned_bytes: 500_000, + }, + ); + assert!(ws.pages.is_empty()); + assert_eq!(ws.persona, sample_persona()); + assert!(ws.invariants_hold()); + } + + /// What this catches: a working set with a Warm-role page on UMA + /// capacity (warm_bytes == 0) fails the invariant check. This is + /// the "structural impossibility of Fast→Warm eviction on UMA" + /// guarantee at the data layer — PR-2's trait will enforce on + /// insertion; PR-1 pins that the invariant function catches it + /// if a future PR ever lets a Warm page slip through. + #[test] + fn working_set_invariant_rejects_warm_page_on_uma_capacity() { + let mut ws = WorkingSet::new( + sample_persona(), + WorkingSetCapacity { + fast_bytes: 1_000_000, + warm_bytes: 0, // UMA shape + max_pinned_bytes: 500_000, + }, + ); + let page = sample_page(); + let key = serde_json::to_string(&page).unwrap(); + ws.pages.insert( + key, + ResidentPage { + page, + role: TierRole::Warm, + last_access_ms: 0, + access_count_window: 0, + pinned: false, + }, + ); + assert!( + !ws.invariants_hold(), + "Warm page on UMA (warm_bytes=0) must violate invariant" + ); + } + + /// What this catches: PageFault serializes from_role as optional — + /// `None` (true cold miss) becomes a missing field on the wire, not + /// `null`. Lets the TS consumer narrow with `if (fault.fromRole)`. + #[test] + fn page_fault_serializes_from_role_as_optional() { + let cold_miss = PageFault { + page: sample_page(), + from_role: None, + to_role: TierRole::Fast, + persona: sample_persona(), + elapsed_us: 1234, + eviction_cost: None, + }; + let j = serde_json::to_string(&cold_miss).unwrap(); + // ts(optional) + Option: serde omits None fields when + // skip_serializing_if is set; without it, None serializes as + // null. The current shape uses ts(optional) for the TS side + // but doesn't add skip_serializing_if, so the wire is + // `"fromRole":null`. This test pins which one we ship — if a + // future PR adds skip_serializing_if, it should be a + // deliberate flip. + assert!( + j.contains("\"fromRole\":null") || !j.contains("\"fromRole\""), + "expected fromRole to be null or omitted, got: {j}" + ); + + let tier_promo = PageFault { + page: sample_page(), + from_role: Some(TierRole::Bench), + to_role: TierRole::Fast, + persona: sample_persona(), + elapsed_us: 500, + eviction_cost: None, + }; + let j2 = serde_json::to_string(&tier_promo).unwrap(); + assert!(j2.contains("\"fromRole\":\"bench\""), "got {j2}"); + } + + /// What this catches: AccessDenied implements Display + Error so + /// audit-recorder + handlers can use it via `?` chains. The + /// Display format includes the actor + page context so a debugger + /// reading the log can act without joining tables. + #[test] + fn access_denied_implements_error_with_context() { + let denied = AccessDenied { + actor: sample_persona(), + page: sample_page(), + owner: Some(sample_persona()), + reason: "cross-persona read of private engram".to_string(), + }; + let _: &dyn std::error::Error = &denied; + let display = format!("{denied}"); + assert!(display.contains("access denied")); + assert!(display.contains("cross-persona read")); + } + + /// What this catches: round-trip integrity across the bigger + /// payloads. If a future PR changes a field name or type in + /// PageFault / EvictionRecord / WorkingSet, the round-trip fails. + #[test] + fn larger_records_round_trip_through_serde() { + let evict = EvictionRecord { + page: sample_page(), + from_role: TierRole::Fast, + to_role: Some(TierRole::Bench), + policy_fired: super::super::tier::EvictionPolicy::LruWithinTurn, + elapsed_us: 42, + }; + let j = serde_json::to_string(&evict).unwrap(); + let back: EvictionRecord = serde_json::from_str(&j).unwrap(); + assert_eq!(evict, back); + + let fault = PageFault { + page: sample_page(), + from_role: Some(TierRole::Cold), + to_role: TierRole::Fast, + persona: sample_persona(), + elapsed_us: 9876, + eviction_cost: Some(evict.clone()), + }; + let j = serde_json::to_string(&fault).unwrap(); + let back: PageFault = serde_json::from_str(&j).unwrap(); + assert_eq!(fault, back); + } + + /// What this catches: a sample shape for downstream consumers to + /// reference. If PageHandle's wire form changes, the consumers' + /// fixtures break. Pin a small concrete example here as a regression + /// check. + #[test] + fn page_handle_sample_shape() { + let handle = PageHandle { + page: sample_page(), + tier_role: TierRole::Fast, + size_bytes: 1_048_576, + }; + let j: serde_json::Value = serde_json::to_value(&handle).unwrap(); + assert_eq!(j["tierRole"], json!("fast")); + assert_eq!(j["sizeBytes"], json!(1_048_576)); + } +} diff --git a/src/workers/continuum-core/src/lib.rs b/src/workers/continuum-core/src/lib.rs index a0a992265..f76c97505 100644 --- a/src/workers/continuum-core/src/lib.rs +++ b/src/workers/continuum-core/src/lib.rs @@ -26,6 +26,7 @@ pub mod concurrency; pub mod concurrent; pub mod ffi; pub mod forge; +pub mod genome; pub mod gpu; pub mod http; pub mod inference;