Skip to content

Commit 5499d3a

Browse files
committed
test(kernel-core): cross-crate integration suite
External-consumer tests over the public surface. Covers the seams the in-crate unit tests did not: schedule_tick (selection, tie-break, empty, tick count), the clock session-envelope guard on both tick paths, a multi-step observation sequence (FIFO + monotonic sequence), and the 0.3.0 constrained-deadline feature reached through the kernel's public surface. Tests-only; no version bump.
1 parent df9dee9 commit 5499d3a

2 files changed

Lines changed: 309 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,31 @@ The workspace versions all 8 crates lock-step (`axonos-capability`,
1010

1111
---
1212

13+
## [Unreleased]
14+
15+
### Added — cross-crate integration tests (`axonos-kernel-core`)
16+
17+
A `tests/integration.rs` suite that exercises the assembled kernel as an
18+
**external consumer**, reaching every type through the crate's public surface
19+
and the crates it re-exports. Tests-only: no library change, no public API
20+
change, no version increment.
21+
22+
It concentrates on the seams the in-crate unit tests did not cover:
23+
24+
- **`schedule_tick`** — previously untested: earliest-deadline selection,
25+
tie-breaking by task id, the empty-ready-set case, and tick-counter advance.
26+
- **The clock session-envelope guard** — both the observation path and the
27+
scheduling path reject a clock whose time lies beyond the session envelope.
28+
- **A multi-step observation sequence** — three observations produced through
29+
the capability gate, drained in FIFO order with monotonically increasing
30+
sequence numbers, ring then empty.
31+
- **Constrained-deadline reachability** — the 0.3.0 `Task::periodic_with_deadline`
32+
composes through kernel construction, and `processor_demand_feasible` is
33+
reachable through the kernel's public surface, catching a constrained set the
34+
utilisation admission test admits.
35+
36+
---
37+
1338
## [v0.3.0] — 2026-05-29
1439

1540
### Added — constrained-deadline scheduling (`axonos-scheduler`)
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// SPDX-License-Identifier: Apache-2.0 OR MIT
2+
// Copyright (c) 2026 Denis Yermakou <connect@axonos.org>
3+
// Part of the AxonOS project — https://github.com/AxonOS-org
4+
5+
//! Cross-crate integration tests for `axonos-kernel-core`.
6+
//!
7+
//! Unlike the unit tests inside the crate, these compile as an **external
8+
//! consumer**: they reach every type through the crate's public surface —
9+
//! `axonos_kernel_core` and the crates it re-exports (`axonos_scheduler`,
10+
//! `axonos_capability`, `axonos_intent`, `axonos_spsc`, `axonos_time`) — and
11+
//! so they verify two things at once: that the assembled kernel behaves
12+
//! correctly across the seams between its five foundational crates, and that
13+
//! its public API is usable from outside without reaching into internals.
14+
//!
15+
//! Coverage here is deliberately complementary to the in-crate unit tests. It
16+
//! concentrates on the seams those tests do not exercise — the scheduling
17+
//! decision (`schedule_tick`), the clock session-envelope guard, a multi-step
18+
//! observation sequence, and the reachability of the constrained-deadline
19+
//! feasibility feature through the public surface.
20+
21+
use axonos_kernel_core::axonos_capability::{Capability, CapabilitySet, Manifest};
22+
use axonos_kernel_core::axonos_intent::{
23+
Confidence, Direction, IntentObservation, Kind, NavigationDirection,
24+
};
25+
use axonos_kernel_core::axonos_scheduler::{
26+
processor_demand_feasible, Feasibility, Instant as SchedInstant, Micros, Task, TaskId,
27+
TaskInstance, TaskSet,
28+
};
29+
use axonos_kernel_core::axonos_time::{Instant, MockClock};
30+
use axonos_kernel_core::{new_ipc_channel, BciKernel, KernelConfig, KernelInitError, TickError};
31+
32+
/// The reference five-task BCI pipeline, assembled through the public API as
33+
/// an external consumer would. Holds the `Navigation` and `SessionQuality`
34+
/// capabilities.
35+
fn reference_kernel() -> BciKernel<MockClock, 8, 64> {
36+
let mut config: KernelConfig<8, 64> = KernelConfig::new();
37+
config
38+
.add_task(Task::periodic(TaskId(1), Micros(642), Micros(4000)))
39+
.unwrap();
40+
config
41+
.add_task(Task::periodic(TaskId(2), Micros(12), Micros(4000)))
42+
.unwrap();
43+
config
44+
.add_task(Task::periodic(TaskId(3), Micros(18), Micros(4000)))
45+
.unwrap();
46+
config
47+
.add_task(Task::periodic(TaskId(4), Micros(24), Micros(4000)))
48+
.unwrap();
49+
50+
let manifest = Manifest::new(
51+
CapabilitySet::singleton(Capability::Navigation).with(Capability::SessionQuality),
52+
);
53+
54+
BciKernel::new(config, manifest, MockClock::new()).expect("reference pipeline must admit")
55+
}
56+
57+
#[test]
58+
fn kernel_assembles_through_public_api() {
59+
// The whole assembly is reachable from outside the crate, and the
60+
// admission and response-time results agree with the pipeline's figures.
61+
let kernel = reference_kernel();
62+
assert!(kernel.utilisation_scaled() > 170_000);
63+
assert!(kernel.utilisation_scaled() < 180_000);
64+
assert_eq!(kernel.response_time_bound(), Micros(696));
65+
assert!(kernel.manifest().requested.contains(Capability::Navigation));
66+
}
67+
68+
#[test]
69+
fn admission_failure_surfaces_at_construction() {
70+
// Two tasks at 50% utilisation each: 1.0 total, far above the ceiling.
71+
let mut config: KernelConfig<4, 32> = KernelConfig::new();
72+
config
73+
.add_task(Task::periodic(TaskId(1), Micros(2000), Micros(4000)))
74+
.unwrap();
75+
config
76+
.add_task(Task::periodic(TaskId(2), Micros(2000), Micros(4000)))
77+
.unwrap();
78+
let manifest = Manifest::new(CapabilitySet::singleton(Capability::Navigation));
79+
let result = BciKernel::new(config, manifest, MockClock::new());
80+
assert!(matches!(result, Err(KernelInitError::Admission(_))));
81+
}
82+
83+
// ── The scheduling decision seam (untested by the in-crate unit tests) ──────
84+
85+
#[test]
86+
fn schedule_tick_selects_earliest_deadline() {
87+
let mut kernel = reference_kernel();
88+
89+
// Two ready instances released together; task 2's deadline (period 2000)
90+
// is earlier than task 1's (period 4000), so EDF must pick task 2.
91+
let ready = [
92+
TaskInstance {
93+
task: Task::periodic(TaskId(1), Micros(100), Micros(4000)),
94+
released_at: SchedInstant(1000),
95+
},
96+
TaskInstance {
97+
task: Task::periodic(TaskId(2), Micros(100), Micros(2000)),
98+
released_at: SchedInstant(1000),
99+
},
100+
];
101+
102+
let picked = kernel
103+
.schedule_tick(&ready)
104+
.expect("clock is within envelope");
105+
assert_eq!(picked, Some(2));
106+
}
107+
108+
#[test]
109+
fn schedule_tick_breaks_ties_by_lower_task_id() {
110+
let mut kernel = reference_kernel();
111+
112+
// Equal absolute deadlines: the lower task id wins, deterministically.
113+
let ready = [
114+
TaskInstance {
115+
task: Task::periodic(TaskId(7), Micros(100), Micros(4000)),
116+
released_at: SchedInstant(1000),
117+
},
118+
TaskInstance {
119+
task: Task::periodic(TaskId(3), Micros(100), Micros(4000)),
120+
released_at: SchedInstant(1000),
121+
},
122+
];
123+
124+
assert_eq!(kernel.schedule_tick(&ready).unwrap(), Some(3));
125+
}
126+
127+
#[test]
128+
fn schedule_tick_with_no_ready_tasks_returns_none() {
129+
let mut kernel = reference_kernel();
130+
let ready: [TaskInstance; 0] = [];
131+
assert_eq!(kernel.schedule_tick(&ready).unwrap(), None);
132+
}
133+
134+
#[test]
135+
fn schedule_tick_advances_the_tick_counter() {
136+
let mut kernel = reference_kernel();
137+
assert_eq!(kernel.tick_count(), 0);
138+
let ready: [TaskInstance; 0] = [];
139+
kernel.schedule_tick(&ready).unwrap();
140+
kernel.schedule_tick(&ready).unwrap();
141+
assert_eq!(kernel.tick_count(), 2);
142+
}
143+
144+
// ── The observation pipeline seam: capability gate → intent → IPC ──────────
145+
146+
#[test]
147+
fn observation_sequence_drains_in_order_with_monotonic_sequence() {
148+
let mut kernel = reference_kernel();
149+
let ipc = new_ipc_channel::<64>();
150+
let (mut producer, mut consumer) = ipc.split().unwrap();
151+
152+
// Produce three observations through the capability-gated path.
153+
for _ in 0..3 {
154+
kernel
155+
.produce_observation(
156+
&mut producer,
157+
NavigationDirection::Right,
158+
Confidence::from_q0_16(0x4000),
159+
)
160+
.expect("Navigation is held; the push must succeed");
161+
}
162+
163+
// Drain them in FIFO order; sequence numbers must be 1, 2, 3.
164+
for expected_sequence in 1..=3u32 {
165+
let bytes = consumer.try_pop().expect("an observation is queued");
166+
let decoded = IntentObservation::decode(&bytes).expect("we encoded it; it must decode");
167+
assert_eq!(decoded.kind, Kind::Navigation);
168+
assert_eq!(
169+
decoded.direction,
170+
Direction::Navigation(NavigationDirection::Right)
171+
);
172+
assert_eq!(decoded.sequence, expected_sequence);
173+
}
174+
175+
// The ring is now empty.
176+
assert!(consumer.try_pop().is_none());
177+
}
178+
179+
#[test]
180+
fn observation_requires_the_capability_in_the_manifest() {
181+
// A kernel whose manifest does not hold Navigation must refuse to produce
182+
// a Navigation observation, before touching the IPC ring.
183+
let mut config: KernelConfig<8, 64> = KernelConfig::new();
184+
config
185+
.add_task(Task::periodic(TaskId(1), Micros(100), Micros(4000)))
186+
.unwrap();
187+
let manifest = Manifest::new(CapabilitySet::singleton(Capability::SessionQuality));
188+
let mut kernel: BciKernel<MockClock, 8, 64> =
189+
BciKernel::new(config, manifest, MockClock::new()).unwrap();
190+
191+
let ipc = new_ipc_channel::<64>();
192+
let (mut producer, _consumer) = ipc.split().unwrap();
193+
let result =
194+
kernel.produce_observation(&mut producer, NavigationDirection::Right, Confidence::MIN);
195+
assert!(matches!(
196+
result,
197+
Err(TickError::CapabilityNotInManifest {
198+
required: Capability::Navigation,
199+
})
200+
));
201+
}
202+
203+
// ── The clock session-envelope guard (untested by the in-crate unit tests) ──
204+
205+
#[test]
206+
fn out_of_envelope_clock_is_rejected_by_both_tick_paths() {
207+
// A clock whose time lies beyond the session envelope is a configuration
208+
// error: both the observation path and the scheduling path must refuse it
209+
// rather than emit a result against an implausible timestamp.
210+
let mut config: KernelConfig<8, 64> = KernelConfig::new();
211+
config
212+
.add_task(Task::periodic(TaskId(1), Micros(100), Micros(4000)))
213+
.unwrap();
214+
let manifest = Manifest::new(CapabilitySet::singleton(Capability::Navigation));
215+
let mut kernel: BciKernel<MockClock, 8, 64> =
216+
BciKernel::new(config, manifest, MockClock::starting_at(Instant(u64::MAX))).unwrap();
217+
218+
let ipc = new_ipc_channel::<64>();
219+
let (mut producer, _consumer) = ipc.split().unwrap();
220+
let obs = kernel.produce_observation(
221+
&mut producer,
222+
NavigationDirection::Right,
223+
Confidence::from_q0_16(0x4000),
224+
);
225+
assert!(matches!(obs, Err(TickError::ClockOutOfEnvelope)));
226+
227+
let ready: [TaskInstance; 0] = [];
228+
assert!(matches!(
229+
kernel.schedule_tick(&ready),
230+
Err(TickError::ClockOutOfEnvelope)
231+
));
232+
}
233+
234+
// ── The 0.3.0 constrained-deadline feature, reached through the kernel ──────
235+
236+
#[test]
237+
fn constrained_deadline_task_composes_through_kernel_construction() {
238+
// A constrained-deadline task (D < T), built with the 0.3.0 constructor,
239+
// flows through the kernel config and admits on utilisation as any task
240+
// does. (The kernel's admission is the Liu–Layland utilisation test; the
241+
// exact processor-demand check is exercised separately below.)
242+
let mut config: KernelConfig<8, 64> = KernelConfig::new();
243+
config
244+
.add_task(Task::periodic_with_deadline(
245+
TaskId(1),
246+
Micros(100),
247+
Micros(1000),
248+
Micros(500),
249+
))
250+
.unwrap();
251+
let manifest = Manifest::new(CapabilitySet::singleton(Capability::Navigation));
252+
let kernel =
253+
BciKernel::new(config, manifest, MockClock::new()).expect("U = 0.1 admits comfortably");
254+
assert!(kernel.utilisation_scaled() > 90_000);
255+
assert!(kernel.utilisation_scaled() < 110_000);
256+
}
257+
258+
#[test]
259+
fn processor_demand_criterion_is_reachable_through_the_public_surface() {
260+
// The 0.3.0 feasibility test is part of the kernel's public surface (via
261+
// the re-exported scheduler). It catches a constrained-deadline set that
262+
// the utilisation admission test admits: C = 200 within D = 100 needs more
263+
// work than the deadline allows, though U = 0.2 is well under any ceiling.
264+
let mut set: TaskSet<4> = TaskSet::new();
265+
set.push(Task::periodic_with_deadline(
266+
TaskId(1),
267+
Micros(200),
268+
Micros(1000),
269+
Micros(100),
270+
))
271+
.unwrap();
272+
273+
// The utilisation admission test is satisfied (U = 0.2 <= 0.25):
274+
assert!(set.admit(250_000).is_ok());
275+
276+
// The processor-demand criterion is not, and reports the violating point:
277+
match processor_demand_feasible(&set) {
278+
Feasibility::Infeasible { at, demand } => {
279+
assert_eq!(at, Micros(100));
280+
assert_eq!(demand, 200);
281+
}
282+
other => panic!("expected Infeasible, got {other:?}"),
283+
}
284+
}

0 commit comments

Comments
 (0)