nx_evict_journal.nx source
↩ module page · 154 lines · 5713 B
1// nx_evict_journal.nx -- append-only eviction visibility log.
2//
3// Per cardinal: cooperative-resource-arbitration. Every eviction,
4// demote, throttle, and migration is logged so the user can audit
5// after the fact. This is the structural EXCEED-axis vs Docker's
6// silent kernel OOM-killer: there, a container disappears and you
7// dig through dmesg hoping to find the reason. Here, every event is
8// in ~/.nishi/evict.jsonl with the displacing cell named.
9//
10// Composes:
11// nx_budget -- denied-allocation events log here
12// nx_yield -- DEMOTED / THROTTLED outcomes log here
13// nx_homeostasis -- migration decisions log here
14// nx_attention_class -- the class of both displaced + displacing
15// cells is preserved for forensics
16//
17// V1 ships in-memory ring buffer. The on-disk JSONL append is queued
18// once nx_jsonl_writer lands; for now the journal is queryable via
19// nx_evict_dump pointer access.
20//
21// Gap list (V1 honest perf verdict):
22// - no disk persistence (in-memory only this phase)
23// - no time-windowed query (caller walks full buffer)
24// - ring-buffer wraps silently at capacity (older entries lost)
25// - no peer-mesh propagation for distributed evictions
26//
27// genealogy_id: nishi_cardinal_2026-05-17_cooperative_resource_arbitration
28// lineage_id: substrate_evict_journal_v1
29//
30// nx_safety_envelope:
31// intended_use: "Append-only eviction/demote/throttle log for
32// forensic audit; never silent eviction"
33// sil_target: SIL2
34// evidence: [append_only_invariant, every_event_logged,
35// displacing_cell_named]
36// verdict: NOT_YET_EVALUATED
37
38import "nx_syscalls.nx"
39import "nx_tier.nx"
40
41// ===== Sealed enum: NxEvictReason =================================
42
43const NX_EVR_BUDGET_DENIED: nx_int = 0
44const NX_EVR_YIELD_DEMOTED: nx_int = 1
45const NX_EVR_YIELD_THROTTLED: nx_int = 2
46const NX_EVR_MIGRATED: nx_int = 3
47const NX_EVR_TERMINATED: nx_int = 4 // immune layer killed the cell
48const NX_EVR_N_REASONS: nx_int = 5
49
50func nx_evr_is_valid(r: nx_int) -> nx_int {
51 if r < 0 { return 0 }
52 if r >= NX_EVR_N_REASONS { return 0 }
53 return 1
54}
55
56// ===== Struct: NxEvictEntry ======================================
57//
58// One entry per event. resource_kind aliases NX_RES_* from nx_budget;
59// displaced_by is 0 when the event has no displacing cell (e.g.,
60// over-budget self-denial).
61
62struct NxEvictEntry {
63 ts_us: nx_size,
64 cell_id: nx_int,
65 reason: nx_int,
66 resource_kind: nx_int,
67 attention_class: nx_int,
68 displaced_by: nx_int,
69}
70
71// ===== Struct: NxEvictJournal =====================================
72//
73// Fixed-capacity ring buffer. Head advances on each append; when head
74// reaches capacity it wraps to 0 and the journal is full. count tracks
75// total appends ever (not modular) so callers can detect overwrites.
76
77struct NxEvictJournal {
78 entries: *NxEvictEntry,
79 capacity: nx_size,
80 head: nx_size,
81 count: nx_size,
82}
83
84// Default capacity tuned for NX_TIER_WORKSTATION: 1024 entries =
85// roughly 48 KiB. On NX_TIER_MCU the caller should reduce capacity.
86const NX_EVICT_DEFAULT_CAPACITY: nx_size = 1024
87
88// ===== Constructor ===============================================
89
90func nx_evict_journal_new(capacity: nx_size) -> *NxEvictJournal {
91 let j: *NxEvictJournal = (sys_mmap(40)) as *NxEvictJournal
92 let entry_bytes: nx_size = capacity * 48
93 j.entries = (sys_mmap(entry_bytes)) as *NxEvictEntry
94 j.capacity = capacity
95 j.head = 0
96 j.count = 0
97 return j
98}
99
100// ===== nx_evict_log ===============================================
101//
102// Append one event. Caller supplies ts_us from its monotonic clock so
103// the journal does not pull a syscall on the hot eviction path.
104
105func nx_evict_log(j: *NxEvictJournal,
106 ts_us: nx_size,
107 cell_id: nx_int,
108 reason: nx_int,
109 resource_kind: nx_int,
110 attention_class: nx_int,
111 displaced_by: nx_int) -> nx_int {
112 if nx_evr_is_valid(reason) == 0 { return 1 }
113 let slot: *NxEvictEntry = (j.entries as i64 + (j.head as i64) * 48) as *NxEvictEntry
114 slot.ts_us = ts_us
115 slot.cell_id = cell_id
116 slot.reason = reason
117 slot.resource_kind = resource_kind
118 slot.attention_class = attention_class
119 slot.displaced_by = displaced_by
120 j.head = j.head + 1
121 if j.head >= j.capacity { j.head = 0 }
122 j.count = j.count + 1
123 return 0
124}
125
126// ===== nx_evict_at ================================================
127//
128// Read entry at index. Returns NULL for out-of-range. Caller must not
129// retain the pointer across subsequent nx_evict_log calls; the ring
130// will overwrite that slot in time.
131
132func nx_evict_at(j: *NxEvictJournal, idx: nx_size) -> *NxEvictEntry {
133 if idx >= j.capacity { return (0 as i64) as *NxEvictEntry }
134 return (j.entries as i64 + (idx as i64) * 48) as *NxEvictEntry
135}
136
137// ===== nx_evict_count_for_cell ====================================
138//
139// Walk the live portion of the ring and tally events for one cell.
140// Useful for homeostasis to spot eviction-storm patterns (>= 5 events
141// for the same cell within the journal window is migration signal).
142
143func nx_evict_count_for_cell(j: *NxEvictJournal, cell_id: nx_int) -> nx_int {
144 var hits: nx_int = 0
145 var i: nx_size = 0
146 var live: nx_size = j.count
147 if live > j.capacity { live = j.capacity }
148 while i < live {
149 let e: *NxEvictEntry = (j.entries as i64 + (i as i64) * 48) as *NxEvictEntry
150 if e.cell_id == cell_id { hits = hits + 1 }
151 i = i + 1
152 }
153 return hits
154}