myxce/.kiro/specs/scheduler-sim-discrete-clock/design.md
Rongsong Shen 4b6872d053
docs(spec): scheduler-sim-discrete-clock requirements, design and tasks approved
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 04:16:17 +00:00

34 KiB

Design Document

Overview

Purpose: This feature makes the scheduler simulator's logical clock depend only on the recording, so a replay reproduces every clock-derived value (session start, written beginRound, decision timestamps, SessionSummary.durationNs, and values derived from the session start such as xce_alloc_t.from) exactly, on any host.

Users: Operators and engineers who replay recordings with xce_scheduler --sim (with --sim-verify, --sim-output, --sim-alt-events, or --interactive), and spec libxce-alloc-preason-equality, whose task 5.3 needs a replayed session to start at exactly the recorded beginRound.

Impact: The simulator already jumps its clock to each recorded timestamp; today the default --sim-speed 1 lets it drift with real time between jumps. This design pins simulation mode to the core's existing frozen-clock setting (speed_num = 0), turns --sim-speed into a deprecated option that only logs a warning, removes the REPL speed parameter, and updates the operator docs and the scheduler-core spec. The core clock formula and normal mode are unchanged.

Goals

  • In simulation mode the logical clock changes only at the start snapshot, at each recorded event, and at each recorded session (Req 1).
  • --sim-speed keeps existing command lines working in both modes, with one warning naming its value (Req 2).
  • The REPL no longer offers a speed setting (Req 3).
  • Docs and the scheduler-core spec describe the simulator as it behaves (Req 5).

Non-Goals

  • Pacing a replay against the wall clock (not provided before or after).
  • Removing --sim-speed from the command line (a later release).
  • Changing the .replay format, the core clock formula, or normal-mode clock behavior (Req 4).
  • How from is computed and compared (spec libxce-alloc-preason-equality).

Boundary Commitments

This Spec Owns

  • The simulation-mode clock setting: sim_set_virtual_clock() stores speed_num = 0, speed_den = 1, and no simulation or REPL path sets any other speed.
  • The contract that, in simulation mode, a session's core->session_start equals its recorded beginRound exactly, including when that is earlier than the previous clock value (Req 1.3, 1.4).
  • The --sim-speed command-line behavior: accepted with any value in both modes, no effect, one startup warning (sched_cli_warn_deprecated_options()).
  • The REPL set speed removal message, and the help, set usage and info config text without speed.
  • The simulation sections of src/docs/deployment.md, and the speed-mode passages of .kiro/specs/scheduler-core/requirements.md and design.md listed in requirements.md 5.2.

Out of Boundary

  • xce_core_now()'s formula and the four clock atoms in core.h (kept; only their documentation of which values are used changes).
  • Normal mode: xce_core_set_wallclock_anchor(), driver.c, recording (recording*.c).
  • The .replay schema (src/schema/controller.capnp).
  • from stamping and allocation equality (spec libxce-alloc-preason-equality).
  • The superseded scheduler-framework spec, and scheduler-core brief.md/tasks.md history.

Allowed Dependencies

  • The core clock atoms and xce_core_now() (include/core.h, core_clock.c), read and written only through the existing sim driver functions.
  • The logging macros and mylog_init()/mylog_fini() in src/include/utils.h (WARN; the drain in main.c).
  • argp as already used by main.c.

Revalidation Triggers

  • Any change to sim_set_virtual_clock(), sim_advance_virtual_clock(), the order of the clock jump and xce_core_begin_session() in sim_loop.c, or xce_core_now(): re-run libxce-alloc-preason-equality task 5.3 and this spec's clock tests.
  • Any new code in simulation mode that writes the clock atoms or reads real time.
  • Removing --sim-speed entirely (the later release): update sched_cli_warn_deprecated_options(), the help text, the soak script and deployment.md.

Architecture

Existing Architecture Analysis

  • One core, two drivers (.kiro/steering/scheduler-driver-equivalence.md): driver.c (normal) and sim_loop.c (simulation) drive the same core session protocol. This change is confined to the simulation driver's clock setting, the CLI entry point and the REPL.
  • xce_core_now() = virtual_clock_base + (CLOCK_MONOTONIC_now - real_clock_base) * speed_num / speed_den (core_clock.c:80-119). Core init seeds a neutral 1/1 (core.c:142) and normal mode re-seeds 1/1 in xce_core_set_wallclock_anchor() (core_clock.c:180); speed_num = 0 returns virtual_clock_base exactly.
  • Simulation-mode clock writers: sim_set_virtual_clock() (sim.c:309, called once at sim.c:594), sim_advance_virtual_clock() at each event (sim_loop.c:417, after the endSession return at 412-415) and at each session (sim_loop.c:1528, immediately before the session record opens at 1538 and xce_core_begin_session() sets session_start). repl_set_speed() (repl_params.c:202) is the only other writer and is removed.

Architecture Pattern & Boundary Map

graph TB
    subgraph CLI
        Main[main.c argp]
        Compat[cli_compat.c warn helper]
    end
    subgraph SimDriver
        SimStart[sim.c sim_set_virtual_clock]
        SimLoop[sim_loop.c sim_advance_virtual_clock]
        Repl[repl_params.c repl.c repl_info.c]
    end
    subgraph Core
        Clock[core_clock.c xce_core_now]
        Session[core_session.c session_start]
    end
    Main --> Compat
    Main --> SimStart
    SimStart --> Clock
    SimLoop --> Clock
    Session --> Clock
    Repl --> Clock
  • Selected pattern: reuse the core's frozen-clock setting; no new abstraction (research.md, Option A).
  • Existing patterns preserved: one core, two drivers; the core module stays free of new I/O (test_core_isolation.c).
  • New component: cli_compat.c, one function, so the deprecation warning is unit-testable and has one place to remove later.
  • The REPL only reads the clock after this change.

Technology Stack

Layer Choice / Version Role in Feature Notes
CLI argp (glibc), existing --sim-speed stays an option taking a value No validation of the value
Scheduler C11, existing sources Sim driver, REPL, CLI helper No new dependency
Logging tlog via WARN (utils.h) Deprecation warning Emitted after mylog_init()
Build/Test Meson, Unity New test binary, updated tests src/scheduler/testsuite/meson.build

File Structure Plan

Directory Structure

src/scheduler/
├── include/
│   ├── cli_compat.h                 # NEW: sched_cli_warn_deprecated_options() declaration
│   ├── core.h                       # doc only: speed atoms hold 1/1 (normal) or 0/1 (simulation)
│   └── sim.h                        # remove sched_sim_args_t.sim_speed and the sim_speed_num/den shadow copy; doc updates
├── src/
│   ├── cli_compat.c                 # NEW: the deprecation warning for --sim-speed
│   ├── main.c                       # help text; no validation; call the warning after mylog_init(); stop passing a speed
│   ├── sim.c                        # sim_set_virtual_clock(sim, snapshot_ts) stores speed 0/1
│   ├── sim_loop.c                   # comments on sim_advance_virtual_clock (no drift, exact unclamped set)
│   ├── sim_record.c                 # comment ~88-89 (no "non-frozen --sim-speed")
│   ├── repl_params.c                # delete repl_set_speed(); set speed removal message before the ntok check; usage text
│   ├── repl_info.c                  # info config line without speed=; doc comment
│   ├── repl.c                       # help without "set speed <N>"
│   └── repl_internal.h              # comment references only
├── meson.build                      # add src/cli_compat.c to scheduler_srcs only
└── testsuite/
    ├── test_sim_discrete_clock.c    # NEW: Req 1 clock tests and Req 2 warning tests
    ├── meson.build                  # register the new test (links ../src/cli_compat.c, depends on the scheduler binary)
    └── (existing tests, see Modified Files)
src/docs/deployment.md               # simulation section
.kiro/specs/scheduler-core/requirements.md, design.md   # speed-mode passages (Req 5.2)

Modified Files

  • src/scheduler/include/sim.h — remove sched_sim_args_t.sim_speed (155-157) and xce_sched_sim_driver_t.sim_speed_num/sim_speed_den (90-91); update the comments at 44-50, 60-68, 136, 214-223.
  • src/scheduler/src/sim.c — sim_set_virtual_clock(sim, snapshot_ts) (drop the third parameter; store speed_num = 0, speed_den = 1; no shadow copy); caller at 594.
  • src/scheduler/src/main.c — option help text (92-94); remove the sim_speed default, validation and sim_args.sim_speed assignment (339, 356-368, 373) and the comment at 296-299 that cites it; update the comments at 67-72 (flag meanings live in sched_sim_args_t, which no longer has a speed field) and 74 (the sim_speed field); call sched_cli_warn_deprecated_options(arguments.sim_speed) once after mylog_init() (325) and before the --sim branch (335); when arguments.sim_speed != NULL, immediately after that call (still single-threaded: no picker, allocator or driver thread exists before sched_sim_driver_run() or sched_init()), call mylog_fini() (utils.c:303, tlog_exit()) and then mylog_init() again with the same arguments. tlog writes the log file from its own thread and registers no exit hook (the tlog-1.0.0 Nix package, tinylog_dep at src/scheduler/meson.build:62: no atexit or destructor in tlog.c); tlog_exit() joins that writer only after every buffered line is written (tlog.c:1725-1728, join at 2210), and re-initialising is supported (tlog_exit() clears root_format, tlog.c:2222, which tlog_init() checks at 2135; the file is reopened with O_APPEND, tlog.c:1371). Side effects of the drain: when gzip is missing, tlog's "can not find gzip command" notice is printed to stderr twice (tlog.c:2161-2163), and if the first write rotated an oversized log the drain waits for that compression to finish (tlog.c:1484-1486); both are harmless while single-threaded. This puts the warning on disk before any mode-specific startup, so it survives every exit path: the --sim branch's returns (main.c:352, 391, 396, 399; 366 is removed), the REPL's exit(0) on quit or end of input (repl.c:350), and normal-mode shutdown. tlog_exit() is deliberately not called at the --sim returns or in the REPL's quit: the picker and allocator threads are never joined (sim_loop.c:1374-1385), and tlog_exit() is unsafe while another thread logs (tlog.c:833-841 vs 2213-2219). The unreachable -ENOSYS branch (main.c:380-392; no simulation path returns -ENOSYS) is left untouched.
  • src/scheduler/src/repl_params.c — delete repl_set_speed() and its doc comment (174-227; keep and edit the section banner at 170-172); in repl_cmd_set() handle speed first; remove speed from the usage string (400-402); comment references (22, 44-48, 348).
  • src/scheduler/src/repl_info.c — info config line without speed= (1801-1806); comments (23, 1770-1784).
  • src/scheduler/src/repl.c — delete the help line at 314; comment at 253-254.
  • src/scheduler/src/sim_loop.c, sim_record.c, repl_internal.h, include/core.h — comments only; also repl_params.c:171 (section banner) and :388-394 (the repl_cmd_set() doc comment naming set speed <N>); sim.c:227-230 (says --sim-output timestamps come from a clock that "never runs backwards", which Req 1.3 contradicts); testsuite/meson.build comments at 318-319, 566, 1178 and 1211.
  • src/scheduler/meson.build — add 'src/cli_compat.c' to scheduler_srcs (the list starting at line 1) only; not to the sim isolation library list (~163), since it is not a sim file. The 22 test targets built with foreach f : scheduler_srcs pick it up harmlessly.
  • src/scheduler/testsuite/meson.build — register sched_sim_discrete_clock_test from ['test_sim_discrete_clock.c', '../src/cli_compat.c'] plus the white-box sim #includes the other sim tests use (it must not link scheduler_srcs, whose sched_election.c would clash with the test's stand-in picker/allocator); pass -DXCE_SCHEDULER_BIN="<path>" from scheduler.full_path() (src/scheduler/meson.build:67, defined before subdir('testsuite') at :171) and register it with test('scheduler sim discrete clock test', …, suite: 'scheduler', env: ['ASAN_OPTIONS=detect_leaks=0'], depends: scheduler) like the other scheduler tests.
  • Build-order constraint for tasks: removing the sim.h fields breaks compilation of main.c, sim.c, repl_params.c, repl_info.c and every test file that assigns sim_speed (plus the assignments in test_repl_snapshot_params.c:1164-1165, its shadow-field assertions at 896-914, and test_sim_startup.c:442-443). These edits must land in one task.
  • Tests (mechanical, remove args.sim_speed = 0;/sim_args.sim_speed = …;): test_repl_breakpoints_inspection.c, test_repl_equivalence.c, test_repl_event_echo.c, test_repl_execution_control.c, test_repl_limit_table.c, test_repl_prompt_context.c, test_repl_round_rollup.c, test_repl_step_verbosity.c, test_sim_alt_events.c, test_sim_contention_parity.c, test_sim_e2e_limit_visibility.c, test_sim_e2e_original_motivation.c, test_sim_equivalence.c, test_sim_event_loop.c (its direct core-atom writes at 703-704 stay), test_sim_live_allocation.c, test_sim_print_decision_gate.c, test_sim_record_output.c, test_sim_resource_contention.c, test_sim_round_boundary_reconciliation.c, test_sim_session_ordering.c, test_sim_snapshot_reconcile_gap.c, test_sim_verify.c, test_repl_snapshot_params.c (678, 814), test_sim_startup.c (422, 484, 521, 549, 571).
  • Tests (behavioral): test_sim_startup.c — keep the core.speed_num == 0/speed_den == 1 assertions (now unconditional), drop the shadow-copy assertions (442-443); test_repl_snapshot_params.c — replace test_set_speed_updates_shadow_and_core_atomics with a removal-message test, update test_info_config_reports_current_parameters (no speed=; assert it is absent) and the help assertion (1212); test_repl_help.c — drop set speed <N> from the lists at 655 and 711 and the comments at 8, 555, 649; comment-only updates in test_repl_snapshot_params.c (18-28, 1044-1052, 1125-1135, 1144), test_repl_step_verbosity.c (30-32, 637) and test_repl_resource_formatting.c (16); test_clock.c — the three …_simulation_… speed tests (163-229) become formula tests named for the formula, not for product speed modes (including their RUN_TEST lines at 496-499) and the header comment at 5-18; the normal (1/1) and frozen (0/1) tests stay.
  • Unchanged: test_maxtasks_wiring_sim_driver_parity.c and test_sched_alloc_from_stamp.c (they write the core atoms, which remain). (requirements.md's "27" is the 24 files above that assign sim_speed plus three that write the core speed atoms directly: these two and test_clock.c.)
  • src/docs/deployment.md — 214 (example without --sim-speed), 220 (deprecated, no effect), 223 (no pacing difference between REPL and batch); describe the clock as moving only at recorded timestamps.
  • .kiro/specs/scheduler-core/requirements.md — flag table (359), AC 3 (383-405), AC 12 (750), AC 13 (786-791), AC 14 (797-798, including "strictly monotonically increasing"), AC 22 (1053, 1057, 1059, 1070-1072, 1102), Data Structure Changes Summary (1244).
  • .kiro/specs/scheduler-core/design.md — 5.14 row (299), sim-driver fields (529), test_clock.c mode rows (760), "Simulation fast mode" (783).

System Flows

sequenceDiagram
    participant Main as main.c
    participant Sim as sim.c
    participant Loop as sim_loop.c
    participant Core as core
    Main->>Main: argp parse, mylog_init
    Main->>Main: sched_cli_warn_deprecated_options(sim_speed)
    Main->>Main: if sim_speed given: mylog_fini, mylog_init (drain)
    Main->>Sim: sched_sim_driver_run(args)
    Sim->>Core: sim_set_virtual_clock(snapshot_ts): vbase=snapshot_ts, speed 0/1
    loop each record
        Loop->>Core: event: vbase = event.ts
        Loop->>Core: session: vbase = beginRound
        Loop->>Core: xce_core_begin_session: session_start = xce_core_now = beginRound
        Loop->>Loop: decisions and output use xce_core_now = beginRound
    end
  • Every clock set is exact and unconditional; a value earlier than the current clock is set as is (Req 1.3). endSession does not move the clock (Req 1.2).
  • Because speed_num = 0, nothing between two sets moves the clock: REPL waits, allocator work, output writing, the post-session pause and the end-of-replay park all read the last set value (Req 1.5).

Requirements Traceability

Requirement Summary Components Interfaces Flows
1.1 Clock = start snapshot's timestamp SimClock sim_set_virtual_clock() Startup
1.2 Exact set at each event/session SimClock sim_advance_virtual_clock() Replay loop
1.3 Earlier timestamps set exactly, no error SimClock sim_advance_virtual_clock() Replay loop
1.4 session_start == beginRound SimClock (reads core_session.c:429, unchanged) xce_core_begin_session() Replay loop
1.5 No other clock movement SimClock, ReplSet speed 0/1; repl_set_speed() removed, REPL read-only Replay loop
1.6 Identical logical timestamps and decisions across replays SimClock all of the above Replay loop
1.7 Written beginRound = start time SimClock (sim_loop.c output writes) sched_record_session_ctx_new(xce_core_now()) Replay loop
1.8 durationNs 0, decision ts = beginRound SimClock (sim_loop.c output writes) finalize and decision writes Replay loop
2.1 Accepted in both modes CliCompat, main argp option Startup
2.2 No effect main, SimClock no speed plumbing Startup
2.3 One warning naming the last value CliCompat, main sched_cli_warn_deprecated_options() Startup
2.4 Non-numeric/negative/empty accepted main, CliCompat no validation Startup
2.5 No warning without the option CliCompat NULL argument Startup
2.6 Help text main argp table —
3.1 set speed message, no state change ReplSet repl_cmd_set() REPL
3.2 Returns to prompt, keeps reading ReplSet returns false REPL
3.3 Help and usage without speed ReplSet, ReplHelp text REPL
3.4 info config without speed ReplInfo text REPL
3.5 Other set parameters unchanged ReplSet repl_cmd_set() REPL
4.1 Normal mode unchanged (none changed) xce_core_set_wallclock_anchor() untouched —
4.2 Old recordings replay SimClock reader unchanged Replay loop
4.3 .replay format unchanged (none changed) schema untouched —
5.1 Operator docs Docs deployment.md —
5.2 scheduler-core spec Docs requirements.md, design.md —
5.3 In-repo callers keep passing CliCompat, main soak script unchanged Startup

Components and Interfaces

Component Domain/Layer Intent Req Coverage Key Dependencies Contracts
SimClock (sim.c, sim_loop.c) Sim driver Pin speed 0/1; exact sets 1.1-1.8, 2.2, 4.2 core clock atoms (P0) State
CliCompat (cli_compat.c) CLI Deprecation warning 2.1, 2.3, 2.4, 2.5, 5.3 WARN (P0) Service
main (main.c) CLI Option table, call order, log drain 2.1, 2.2, 2.3, 2.4, 2.6, 5.3 argp, CliCompat (P0) —
ReplSet / ReplHelp / ReplInfo (repl_params.c, repl.c, repl_info.c) REPL Remove speed 1.5, 3.1-3.5 — —
Docs (deployment.md, scheduler-core spec) Docs Describe actual behavior 5.1, 5.2 — —

Sim driver

SimClock

Field Detail
Intent Keep the simulation-mode logical clock at the last recorded timestamp set
Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 2.2, 4.2

Responsibilities & Constraints

  • sim_set_virtual_clock() is the only place that sets the speed in simulation mode, and it sets 0/1.
  • sim_advance_virtual_clock() sets virtual_clock_base (and real_clock_base) unconditionally: no clamping, no warning, no error for a value earlier than the current clock.
  • The clock jump to beginRound (sim_loop.c:1528) stays before both the output session record opening (1538) and xce_core_begin_session() (1548); with speed 0 that is what makes 1.4, 1.7 and 1.8 hold (the relative order of the last two does not matter). The record is opened from xce_core_now(), not core.session_start, because it opens before xce_core_begin_session() sets that field.

Contracts: State [x]

State Management
/* sim.c — replaces sim_set_virtual_clock(sim, snapshot_ts, sim_speed) */
static void sim_set_virtual_clock(xce_sched_sim_driver_t* sim,
                                  uint64_t                snapshot_ts);
  • Postconditions: core.virtual_clock_base == snapshot_ts, core.speed_num == 0, core.speed_den == 1, so xce_core_now(&sim->core) == snapshot_ts until the next sim_advance_virtual_clock().
  • Invariant (simulation mode): xce_core_now() returns the most recent value passed to sim_set_virtual_clock() or sim_advance_virtual_clock(), regardless of elapsed real time.

sched_sim_args_t loses sim_speed; xce_sched_sim_driver_t loses sim_speed_num and sim_speed_den. The core's speed_num/speed_den remain; core.h documents their two used values (1/1 normal, 0/1 simulation).

CLI

CliCompat

Field Detail
Intent Log the one deprecation warning for --sim-speed
Requirements 2.1, 2.3, 2.4, 2.5, 5.3

Contracts: Service [x]

Service Interface
/* include/cli_compat.h */

/*
 * sched_cli_warn_deprecated_options - logs one WARN per deprecated option
 * that was given. sim_speed is the raw --sim-speed value as argp stored it
 * (the last occurrence), or NULL when the option was not given.
 */
void sched_cli_warn_deprecated_options(const char* sim_speed);
  • Preconditions: called once, after mylog_init(), before any mode-specific startup.
  • Postconditions: sim_speed == NULL logs nothing; otherwise exactly one WARN line containing --sim-speed, the words "deprecated" and "no effect", "will be removed", and the value in quotes (so an empty value is visible as "").
  • Never fails, never exits, never parses the value.

Implementation Notes

  • main.c keeps OPT_SIM_SPEED with argument name "N" so a trailing --sim-speed stays an argp error (Req 2.4); help text: "Deprecated, has no effect: the simulator clock moves only at recorded timestamps. Will be removed in a future release."

REPL

ReplSet / ReplHelp / ReplInfo

Field Detail
Intent Remove the speed parameter from the REPL
Requirements 1.5, 3.1, 3.2, 3.3, 3.4, 3.5

Implementation Notes

  • repl_cmd_set(): if ntok >= 2 and tokens[1] is "speed", print [sim] set speed: the speed parameter was removed; the simulator clock moves only at recorded timestamps and return false (re-prompt, same as help); nothing else changes. Then the existing ntok < 3 usage check, whose text becomes set output <FILE>|off | set verify on|off | set units raw|human|both.
  • repl_info_config() prints [sim] config: output=%s verify=%s events_processed=%llu current_ts=%llu (%s) sessions=%llu.
  • repl.c help drops the set speed <N> line; set output, set verify, set units lines are unchanged.

Data Models

No persisted or wire data changes. The .replay schema is untouched (Req 4.3). In-memory changes: sched_sim_args_t.sim_speed and xce_sched_sim_driver_t.sim_speed_num/sim_speed_den are removed; the core's four clock atoms are unchanged. Simulation output values change only in content: SessionSummary.durationNs becomes 0 and ReplayDecision.ts equals the session's beginRound; in simulation mode the snapshot blob's diagnostic session_duration_ns is 0 after any replayed session, while a snapshot written before the first replayed session ends (the --sim-output initial SnapshotEntry, or a REPL snapshot/set output at the pre-replay pause) carries the value loaded from the recorded snapshot (snapshot_load.c:448-449).

Error Handling

  • --sim-speed never produces an error or a non-zero exit, except the argp error for a missing value, which is unchanged.
  • A recorded timestamp earlier than the current clock is not an error (Req 1.3).
  • set speed is not an error; it returns to the prompt.
  • When --sim-speed is given, main() drains the logger right after logging the warning (mylog_fini() then mylog_init(), while still single-threaded), so the warning is on disk before any mode-specific startup, whether the process later returns from main() or exits through the REPL's exit(0). The drain's only other observable effect, a repeated tlog "can not find gzip command" notice on stderr when gzip is absent, is accepted as outside Req 2.2's "only difference" (tlog's own diagnostic, not scheduler behavior; stdout, log content and simulation output are unaffected).
  • Monitoring: the startup WARN is the only new log line; it goes to --logfile when given, otherwise to the default /tmp/xce_scheduler.log (main.c:216), in both modes.

Testing Strategy

New test binary test_sim_discrete_clock.c (registered as scheduler sim discrete clock test), using the in-process fixtures and stand-in picker/allocator pattern of the existing sim tests (e.g. test_sim_record_output.c, test_sim_session_ordering.c):

  • Unit (SimClock):
    • After sched_sim_driver_start(), xce_core_now() equals the chosen snapshot's timestamp, stays equal after a real sleep (≥ 20 ms), and the core holds speed_num == 0, speed_den == 1 (1.1, 1.5).
    • A replay whose second session's beginRound is earlier than the preceding event's timestamp: that session's session_start equals its beginRound, and the replay completes without error. Second case, as the normal-mode recorder produces: events whose ts is earlier than the start snapshot's timestamp; after each such event xce_core_now() equals that event's ts (observed by calling the static sim_process_replay_event_message() directly, reachable through the white-box #include of sim_loop.c), and the replay completes (1.3). A fixture whose last recorded event (ts = T) is followed by a second SnapshotEntry with ts = S and then the endSession marker (whose ts differs from both T and S, e.g. 0 as test_sim_record_output.c's append_end_of_stream_marker() writes), where S differs from T and is greater than the initial snapshot's ts (a SnapshotEntry can be hand-built as test_sim_startup.c:369-390 does): run it through sched_sim_driver_run() in batch mode with the stand-in picker/allocator and args.sim_start_ts set to the initial snapshot's ts, so sim.c:218 does not pick S as the start snapshot and the real skip branch at sim_loop.c:1684-1692 runs; after the run returns 0, and again after a real sleep (≥ 20 ms), xce_core_now() equals T (1.5: mid-replay snapshot skip, and after the end of the recording).
  • Integration (replay loop):
    • With a stand-in allocator that sleeps (≥ 10 ms) before deciding and a recorded beginRound that is not a whole second: core->session_start, recorded by the stand-in allocator on every cbuf_get return (including the jobid == 0 terminator, so a session with no pending jobs is still observed), equals beginRound exactly (1.2, 1.4), and simulation output (sim->record_file to a temp file) has each session's beginRound equal to the recorded one, summary.durationNs == 0, and every ReplayDecision.ts == beginRound (1.7, 1.8).
    • The same recording replayed twice to two output files: identical session start times, ReplayDecision.ts, durationNs, snapshot timestamps and recordingStartTs, and identical decision sequences; recordingStartWall is not compared (1.6). The REPL's reported position (prompt and info session) reads xce_core_now(), so its equality across runs follows from the SimClock invariant proven above.
    • Fixtures written by the unchanged recorder and fixture writers (the format is untouched, 4.3) replay without error across the whole sim suite, as do the /xce-test-ha soak's recordings (4.2).
  • Unit (CliCompat), with a local copy of the capture_stdout_around() helper (each test file carries its own, e.g. test_repl_help.c); the test binary must not call mylog_init(), so WARN takes tlog's early-print path to stdout (tlog.c:838-839): "0", "abc", "-1" and "" each produce exactly one line naming the value and containing --sim-speed, "deprecated", "no effect" and "will be removed"; NULL produces no output (2.3, 2.4, 2.5).
  • REPL (update test_repl_snapshot_params.c, test_repl_help.c): set speed 7 and bare set speed print the removal message, return false, and leave xce_core_now() and the core atoms unchanged; set usage and help do not contain speed; info config does not contain speed= and still shows output=, verify=, events_processed=, current_ts=, sessions=; set output, set verify, set units tests pass unchanged; set speed 7 followed by info config and quit in a script file that a forked child freopens as stdin (the child_step_snapshot_quit() pattern, test_repl_snapshot_params.c:656-690; stdout freopened to a capture file; the parent waitpids): the child exits 0 and the capture contains both the removal message and the [sim] config: line (a non-tty stdin exercises the same readline() path as a pipe) (3.1-3.5).
  • Regression: the full scheduler suite (every sim and REPL test now runs at the pinned speed with no speed field), test_clock.c normal-mode 1/1 test and test_sched_alloc_from_stamp.c normal-mode test unchanged (4.1); test_core_isolation.c passes (core untouched).
  • Manual/soak (5.3): /xce-test-ha runs its --sim-verify --sim-speed=0 step unmodified and passes; the step's log contains the deprecation warning.
  • CLI help (2.6): in test_sim_discrete_clock.c, popen(XCE_SCHEDULER_BIN " --help") and assert the --sim-speed entry contains "Deprecated" and the whole output contains neither "real-time" nor "as-fast-as-possible".
  • CLI end-to-end (1.4, 1.7, 1.8 on the default command line; 2.1-2.5), in test_sim_discrete_clock.c, via popen() of XCE_SCHEDULER_BIN (the child inherits the test's ASAN_OPTIONS=detect_leaks=0) against a fixture the test writes, whose beginRound is not a whole second. The in-process tests above cannot fail on the pre-change code, because a zero-initialized sched_sim_args_t already selected speed 0; this test is the one that does (default speed 1 at main.c:339, strtoull rejection at main.c:356-368).
    • (a) --sim FIXTURE --sim-output OUT0 --logfile LOG0 with no --sim-speed exits 0; OUT0's sessions have beginRound equal to the recorded value, summary.durationNs == 0, and every ReplayDecision.ts == beginRound; LOG0 is absent or has no --sim-speed line (1.7, 1.8, 2.5; 1.4 on the default command line follows because session_start (core_session.c:429) and the written beginRound (sim_loop.c:1538) are both xce_core_now() reads with no clock write between them, and 1.4 itself is proven in-process). The fixture contains a queue update and a pending job before each session (as test_sim_record_output.c's append_queue_update_event()/append_jobs_push_event() do), and the test asserts at least one decision per session, so the ts check is not vacuous.
    • (b) The same command, but with a fresh --sim-output OUTn and --logfile LOGn for each run, plus each of --sim-speed 7, --sim-speed abc, --sim-speed -1 and --sim-speed= exits 0, produces output identical to OUT0 in every field except recordingStartWall (compared byte-for-byte after the first message; the ReplayHeader is decoded with decode_ReplayHeader and compared field by field except recordingStartWall) (2.2), and logs exactly one line containing --sim-speed and the quoted value (2.1, 2.3, 2.4).
    • (c) --sim-speed 1 --sim-speed 9, with its own fresh --sim-output and --logfile, logs exactly one such line, naming "9" (2.3).
    • (d) --sim FIXTURE --sim-speed with no value exits non-zero (argp usage error) (2.4).
    • (e) --sim FIXTURE -i --sim-speed 3 --logfile LOGI < /dev/null (end of input → quit → exit(0), repl.c:350) exits 0, and LOGI contains exactly one line containing --sim-speed and "3" (2.3 on the REPL exit path).
    • Mechanics: the test reads the child's stdout to EOF before pclose() (or redirects it to /dev/null in the command), since the child prints decision lines, and takes the exit code from WEXITSTATUS(pclose(...)).
    • Normal mode (2.1, 2.3) is covered by the CliCompat unit test plus the call site's placement before the --sim branch in main.c, because normal mode needs a controller.

Project Invariant Cross-Check

Scheduler dimensions from .claude/skills/xce-audit-invariants/SKILL.md:

  1. Driver state-equivalence: only the simulation driver's clock setting changes; the session protocol calls in sim_loop.c and driver.c are untouched. A replayed session_start and written beginRound now equal the recorded values exactly; durationNs = 0 and decision ts = beginRound are intentional simulation-output differences (requirements, Adjacent expectations); decisions and accounting are unaffected. Pass.
  2. Core module I/O isolation: no core-family source changes except comments in core.h. Pass.
  3. Picker→allocator jbuf capacity-1 rendezvous: untouched. Pass.
  4. Snapshot save/load field completeness: no persistence change; session_start and session_duration_ns still round-trip unchanged, and after a replayed session their values are beginRound and 0. Pass.