companion-runtime-manager-data-integrity.test.mjs
281 lines 11.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tier 5 — DATA INTEGRITY: lib/companion-runtime-manager.mjs
3 *
4 * Verifies the data-integrity invariants of the pure module:
5 * - All decision functions are deterministic (same inputs → same outputs).
6 * - State update functions never mutate their inputs (pure / immutable contract).
7 * - All returned reason strings are members of RUNTIME_MANAGER_REASONS (no free-form).
8 * - canServeInference is strictly state-gated (no false positive possible).
9 * - Module exports no env-reading, no I/O, no network entry points.
10 * - Lifecycle transition table is complete and sound (no unreachable states).
11 *
12 * Reference: docs/COMPANION-APP-PHASE-4-RUNTIME-MANAGER.md §2 (integrity), §3 (lifecycle).
13 */
14
15 import { describe, it } from 'node:test';
16 import assert from 'node:assert/strict';
17 import crypto from 'node:crypto';
18
19 import {
20 RUNTIME_MANAGER_REASONS,
21 LIFECYCLE_STATES,
22 LIFECYCLE_EVENTS,
23 createLifecycleState,
24 transitionLifecycle,
25 canServeInference,
26 createAdmissionState,
27 evaluateAdmission,
28 recordInFlight,
29 recordCompletion,
30 recordQueued,
31 recordDequeued,
32 createResourceLimits,
33 evaluateResourceLimits,
34 evaluateRuntimeRequest,
35 validateSourceUrl,
36 validateIntegritySpec,
37 createIntegrityAccumulator,
38 verifyModelBytes,
39 } from '../lib/companion-runtime-manager.mjs';
40
41 function makeDigest(data) {
42 return crypto.createHash('sha256').update(data).digest('hex');
43 }
44
45 const ALLOWED_URLS = ['https://models.example.com/'];
46 const VALID_URL = 'https://models.example.com/model.gguf';
47 const VALID_DATA = Buffer.from('deterministic model data for integrity tests');
48 const VALID_DIGEST = makeDigest(VALID_DATA);
49 const VALID_SIZE = VALID_DATA.length;
50
51 const ALL_REASONS = new Set(Object.values(RUNTIME_MANAGER_REASONS));
52 const READY = { state: LIFECYCLE_STATES.READY };
53 const VALID_LIMITS = createResourceLimits({ maxRamBytes: 8e9, maxVramBytes: 4e9, maxCpuPercent: 80 });
54 const VALID_OBS = { ramBytes: 1e9, vramBytes: 0.5e9, cpuPercent: 10 };
55
56 // ── Determinism ───────────────────────────────────────────────────────────────
57
58 describe('determinism: same inputs → same outputs', () => {
59 it('validateSourceUrl is deterministic over 1000 calls', () => {
60 for (let i = 0; i < 1000; i++) {
61 const r = validateSourceUrl(VALID_URL, ALLOWED_URLS);
62 assert.equal(r.ok, true);
63 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.OK);
64 }
65 });
66
67 it('validateIntegritySpec is deterministic over 1000 calls', () => {
68 for (let i = 0; i < 1000; i++) {
69 const r = validateIntegritySpec(VALID_DIGEST, VALID_SIZE);
70 assert.equal(r.ok, true);
71 }
72 });
73
74 it('canServeInference is deterministic: ready always returns true 1000 times', () => {
75 for (let i = 0; i < 1000; i++) {
76 assert.equal(canServeInference(READY), true);
77 }
78 });
79
80 it('canServeInference is deterministic: stopped always returns false 1000 times', () => {
81 const stopped = createLifecycleState();
82 for (let i = 0; i < 1000; i++) {
83 assert.equal(canServeInference(stopped), false);
84 }
85 });
86
87 it('evaluateAdmission is deterministic over 1000 calls', () => {
88 const s = { ...createAdmissionState({ maxInFlight: 4, queueBound: 8 }), inFlight: 2 };
89 for (let i = 0; i < 1000; i++) {
90 const r = evaluateAdmission(s);
91 assert.equal(r.ok, true);
92 }
93 });
94
95 it('evaluateResourceLimits is deterministic over 1000 calls', () => {
96 for (let i = 0; i < 1000; i++) {
97 const r = evaluateResourceLimits(VALID_OBS, VALID_LIMITS);
98 assert.equal(r.ok, true);
99 }
100 });
101
102 it('evaluateRuntimeRequest is deterministic over 1000 calls', () => {
103 const admission = createAdmissionState({ maxInFlight: 10, queueBound: 20 });
104 for (let i = 0; i < 1000; i++) {
105 const r = evaluateRuntimeRequest({
106 lifecycleState: READY,
107 admissionState: admission,
108 resourceObservation: VALID_OBS,
109 resourceLimits: VALID_LIMITS,
110 });
111 assert.equal(r.ok, true);
112 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.OK);
113 }
114 });
115
116 it('transitionLifecycle is deterministic: stopped + start always → starting', () => {
117 const stopped = createLifecycleState();
118 for (let i = 0; i < 1000; i++) {
119 const r = transitionLifecycle(stopped, LIFECYCLE_EVENTS.START);
120 assert.equal(r.ok, true);
121 assert.equal(r.newState.state, LIFECYCLE_STATES.STARTING);
122 }
123 });
124 });
125
126 // ── No input mutation ─────────────────────────────────────────────────────────
127
128 describe('no input mutation: state objects unchanged after update functions', () => {
129 it('transitionLifecycle does not mutate input state', () => {
130 const stopped = createLifecycleState();
131 const original = stopped.state;
132 transitionLifecycle(stopped, LIFECYCLE_EVENTS.START);
133 assert.equal(stopped.state, original);
134 });
135
136 it('recordInFlight does not mutate input state', () => {
137 const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
138 const originalInFlight = s.inFlight;
139 recordInFlight(s);
140 assert.equal(s.inFlight, originalInFlight);
141 });
142
143 it('recordCompletion does not mutate input state', () => {
144 let s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
145 s = recordInFlight(s);
146 const originalInFlight = s.inFlight;
147 recordCompletion(s);
148 assert.equal(s.inFlight, originalInFlight);
149 });
150
151 it('recordQueued does not mutate input state', () => {
152 const s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
153 const originalQueued = s.queued;
154 recordQueued(s);
155 assert.equal(s.queued, originalQueued);
156 });
157
158 it('recordDequeued does not mutate input state', () => {
159 let s = createAdmissionState({ maxInFlight: 4, queueBound: 8 });
160 s = recordQueued(s);
161 const originalQueued = s.queued;
162 recordDequeued(s);
163 assert.equal(s.queued, originalQueued);
164 });
165 });
166
167 // ── Reason-code domain ────────────────────────────────────────────────────────
168
169 describe('all returned reason strings are RUNTIME_MANAGER_REASONS values', () => {
170 const testCases = [
171 () => validateSourceUrl('bad', ALLOWED_URLS),
172 () => validateSourceUrl(VALID_URL, []),
173 () => validateSourceUrl('http://models.example.com/m', ALLOWED_URLS),
174 () => validateIntegritySpec('bad', 10),
175 () => validateIntegritySpec(VALID_DIGEST, 0),
176 () => verifyModelBytes({ fileData: Buffer.from('x'), expectedDigest: VALID_DIGEST, expectedSizeBytes: 5, sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS }),
177 () => transitionLifecycle(createLifecycleState(), 'bad_event'),
178 () => evaluateAdmission(null),
179 () => evaluateAdmission({ ...createAdmissionState({ maxInFlight: 1, queueBound: 1 }), inFlight: 1 }),
180 () => evaluateAdmission({ ...createAdmissionState({ maxInFlight: 1, queueBound: 1 }), inFlight: 1, queued: 1 }),
181 () => evaluateResourceLimits(null, VALID_LIMITS),
182 () => evaluateResourceLimits(VALID_OBS, null),
183 () => evaluateResourceLimits({ ...VALID_OBS, ramBytes: VALID_LIMITS.maxRamBytes + 1 }, VALID_LIMITS),
184 () => evaluateRuntimeRequest(),
185 () => evaluateRuntimeRequest({ lifecycleState: createLifecycleState(), admissionState: null, resourceObservation: VALID_OBS, resourceLimits: VALID_LIMITS }),
186 ];
187
188 for (let i = 0; i < testCases.length; i++) {
189 it(`test case ${i + 1} reason is a RUNTIME_MANAGER_REASONS value`, () => {
190 const r = testCases[i]();
191 assert.ok(ALL_REASONS.has(r.reason), `Unknown reason: "${r.reason}" at case ${i + 1}`);
192 });
193 }
194 });
195
196 // ── canServeInference strict state-gating ────────────────────────────────────
197
198 describe('canServeInference is strictly state-gated', () => {
199 it('only READY state returns true', () => {
200 for (const s of Object.values(LIFECYCLE_STATES)) {
201 const result = canServeInference({ state: s });
202 if (s === LIFECYCLE_STATES.READY) {
203 assert.equal(result, true, `Expected true for READY, got false`);
204 } else {
205 assert.equal(result, false, `Expected false for ${s}, got true`);
206 }
207 }
208 });
209
210 it('any non-object input returns false (no truthy coercion)', () => {
211 const inputs = [null, undefined, 0, '', false, true, 'ready', { state: null }];
212 for (const inp of inputs) {
213 assert.equal(canServeInference(inp), false, `Expected false for ${JSON.stringify(inp)}`);
214 }
215 });
216 });
217
218 // ── Lifecycle transition soundness ────────────────────────────────────────────
219
220 describe('lifecycle transition table is complete and sound', () => {
221 const allStates = Object.values(LIFECYCLE_STATES);
222 const allEvents = Object.values(LIFECYCLE_EVENTS);
223
224 // Valid transitions (each one should succeed)
225 const validTransitions = [
226 [LIFECYCLE_STATES.STOPPED, LIFECYCLE_EVENTS.START, LIFECYCLE_STATES.STARTING],
227 [LIFECYCLE_STATES.STARTING, LIFECYCLE_EVENTS.HEALTH_OK, LIFECYCLE_STATES.READY],
228 [LIFECYCLE_STATES.STARTING, LIFECYCLE_EVENTS.HEALTH_FAIL, LIFECYCLE_STATES.STOPPED],
229 [LIFECYCLE_STATES.READY, LIFECYCLE_EVENTS.DRAIN, LIFECYCLE_STATES.DRAINING],
230 [LIFECYCLE_STATES.DRAINING, LIFECYCLE_EVENTS.STOPPED, LIFECYCLE_STATES.STOPPED],
231 ];
232
233 for (const [from, event, to] of validTransitions) {
234 it(`valid: ${from} + ${event} → ${to}`, () => {
235 const r = transitionLifecycle({ state: from }, event);
236 assert.equal(r.ok, true);
237 assert.equal(r.newState.state, to);
238 });
239 }
240
241 // Every other (state, event) combination is invalid
242 for (const state of allStates) {
243 for (const event of allEvents) {
244 const isValid = validTransitions.some(([f, e]) => f === state && e === event);
245 if (!isValid) {
246 it(`invalid: ${state} + ${event} → fail-closed`, () => {
247 const r = transitionLifecycle({ state }, event);
248 assert.equal(r.ok, false);
249 // Reason must be in the known set
250 assert.ok(ALL_REASONS.has(r.reason), `Unknown reason: ${r.reason}`);
251 });
252 }
253 }
254 }
255 });
256
257 // ── verifyModelBytes: same data → same result ─────────────────────────────────
258
259 describe('verifyModelBytes determinism', () => {
260 it('same valid input → ok:true every call', () => {
261 for (let i = 0; i < 500; i++) {
262 const r = verifyModelBytes({
263 fileData: VALID_DATA, expectedDigest: VALID_DIGEST, expectedSizeBytes: VALID_SIZE,
264 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
265 });
266 assert.equal(r.ok, true);
267 }
268 });
269
270 it('wrong digest → DIGEST_MISMATCH every call', () => {
271 const wrongDigest = '0'.repeat(64);
272 for (let i = 0; i < 500; i++) {
273 const r = verifyModelBytes({
274 fileData: VALID_DATA, expectedDigest: wrongDigest, expectedSizeBytes: VALID_SIZE,
275 sourceUrl: VALID_URL, allowedSourceUrls: ALLOWED_URLS,
276 });
277 assert.equal(r.ok, false);
278 assert.equal(r.reason, RUNTIME_MANAGER_REASONS.DIGEST_MISMATCH);
279 }
280 });
281 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago