myxce/.kiro/specs/libxce-alloc-preason-equality/design.md
Rongsong Shen 094577f245
docs(spec): libxce-alloc-preason-equality tasks approved for implementation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 09:13:29 +00:00

346 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design Document
## Overview
**Purpose**: This feature delivers a single, shared implementation of structural equality for the scheduler's allocation and pending-reason data structures, published as public libxce API, that can explain *why* two objects differ, together with a reproducible allocation start time (`from`) so that `from` can take part in equality.
**Users**: Scheduler engineers (today: `--sim-verify` replay checking and three scheduler test files) and, in future, controller engineers (`persist.c`, `resync.c`, `job.c` already carry these structs) who need to ask "do these two objects represent the same thing, and if not, where do they differ".
**Impact**:
- Moves the three `static` allocation comparators out of `src/scheduler/src/sim_verify.c` into `src/lib/xcealloc.c`. `alloc_eq` now compares `from` and excludes only `instid`.
- Adds a **new** full-structural-match pending-reason comparator to `src/lib/preason.c`. No full-match comparator exists today; bug-1845 narrowed the simulator's to main-reason-only.
- Gives every comparator an optional `char** why` out-parameter that returns a newly allocated description of the first difference, owned by the caller. `sim_log_mismatch` prints it (resolves bug-1881).
- Changes the scheduler to stamp `from` with the scheduling session's start time, a single clock reading shared with the recorded `beginRound`, instead of `time(NULL)`. That a replayed session starts at exactly the recorded `beginRound` comes from the separate spec `scheduler-sim-discrete-clock` (removes `--sim-speed`; the simulator clock moves only at recorded timestamps).
- Switches the simulator and three white-box test consumers to the public API. The three test files' checks become stricter: full-match pending reasons (Decision 7) and `from`-inclusive allocations.
- `sim_decision_eq` and `sim_log_mismatch` stay in the scheduler, and `sim_decision_eq`'s PEND branch keeps a simulator-local main-reason-only comparison (bug-1845). No wire format or struct layout changes.
### Goals
- Publish `alloc_eq`, `host_alloc_eq`, `job_alloc_eq` in `xcealloc.h` and `job_preason_eq` in `preason.h`, each with an optional first-difference description.
- Make `job_preason_eq` a full structural match (main reason + host-based reasons + resource-based reasons, in order).
- Make `from` reproducible under replay, then compare it.
- Make `--sim-verify` mismatch logs name the differing field and values.
- Make the existing consumers call the public API instead of `sim_verify.c`'s private `static` comparators. (The three test files keep their white-box `#include "../src/sim_verify.c"`: they also white-box-include `sim_loop.c`, which calls `sim_decision_eq`/`sim_log_mismatch`.)
- Keep `--sim-verify` free of false mismatches for recordings made after this change.
- Add libxce unit tests that pin the `instid` exclusion, `from` inclusion, full-match preason, description, and NULL-handling semantics.
### Non-Goals
- Do not move `sim_decision_eq`, `sim_log_mismatch`, or the `describe_*` helpers (simulator-specific; depend on capnp/replay types and `ERROR()`).
- Do not change the simulator's PEND outcome check from main-reason-only to full-match — that would reintroduce bug-1845's false mismatches on cache-hit PENDs (see Decision 3).
- Do not introduce any new comparison call site beyond the existing four consumers.
- Do not touch the controller's persistence/resync code paths, or how the controller/worker interpret `from`.
- Do not change `make_alloc()`'s signature or its own `time(NULL)` default (controller and many tests call it); the scheduler overwrites `from` after construction (Decision 8).
- Do not touch `merge_job_alloc()`/`same_alloc_slot()` (bug-1863); `alloc_eq` neither replaces nor reuses `same_alloc_slot` (see Decision 6). (Their NULL-dereference defect, bug-1882, is already fixed in f9146864.)
- Do not remove the test files' white-box `#include "../src/sim_verify.c"`.
- Do not make recordings made before this change verify cleanly; their wall-clock `from` values are an accepted limitation.
- Do not change the simulator's clock or `--sim-speed`; that is spec `scheduler-sim-discrete-clock`, which this spec depends on.
## Boundary Commitments
### This Spec Owns
- The public equality functions `alloc_eq`, `host_alloc_eq`, `job_alloc_eq` (`xcealloc.{c,h}`) and `job_preason_eq` (`preason.{c,h}`), their equality contracts and their difference-description format.
- The shared NULL-safe string-equality helper both modules need (see Decision 5).
- The scheduler's stamping of `from` (`sched_make_primary_alloc_()` in scheduler.c, from the session core) and the single normal-mode session-start reading shared by `core->session_start`, the recorded `beginRound`, and `from` (driver.c).
- The migration of `sim_verify.c` and the three white-box test files onto those functions, and the difference text in `sim_log_mismatch`.
- libxce unit tests for the new functions (`test_xcealloc.c`, `test_preason.c`) and scheduler tests for `from` stamping.
### Out of Boundary
- The decision-level comparison and mismatch reporting structure that remain in `sim_verify.c`, including the main-reason-only PEND outcome check (simulator-local).
- The `.replay` wire format and the `replay_decision_t`/`xce_decision_t` types.
- Any change to the controller's consumption of these structs.
- `merge_job_alloc()` and its `same_alloc_slot()` slot-matching helper in `xcealloc.c`.
- The simulator clock and `--sim-speed` (spec `scheduler-sim-discrete-clock`).
### Allowed Dependencies
- `src/include/xce/types.h` (the struct definitions — unchanged).
- `xcealloc.c`/`xcealloc.h`, `preason.c`/`preason.h`, and `utils.{c,h}` (the modules that already own construction of these structs, plus the shared string helper).
- `string.h` (`strcmp`) and `stdio.h` (`vasprintf`, a GNU extension: `xcealloc.c`/`preason.c` define `_GNU_SOURCE` before their first include, as `client_failover.c:14` does) for comparisons and descriptions.
- Scheduler: `driver.c`, `scheduler.c`, `sim_verify.c`, `sim_loop.c` (the `sim_decision_eq`/`sim_log_mismatch` call sites only).
- Spec `scheduler-sim-discrete-clock`: after it, a replayed session's `core->session_start` equals the recorded `beginRound` exactly.
### Revalidation Triggers
- A change to the `xce_alloc_t`/`xce_host_alloc_t`/`xce_job_alloc_t`/`xce_job_preason_t` struct layouts must re-check the field lists, the exclusion documentation, and the description text.
- A change to `same_alloc_slot()`'s matching rule, or a proposal to merge it with `alloc_eq`, must re-check Decision 6 (the two answer different questions and disagree on `instid`, `from`, and `value`).
- A change to the pending-reason construction paths (cache-hit vs. full-computation) that alters whether detail lists are populated must re-check the simulator's main-reason-only PEND rationale (bug-1845) and the full-match `job_preason_eq` contract.
- Any new allocation path that does not go through `sched_make_primary_alloc_()`, a second clock read between session start and recording, or a change to `scheduler-sim-discrete-clock`'s clock guarantee, must re-check Decision 8.
## Architecture
### Existing Architecture Analysis
`src/scheduler/src/sim_verify.c` (474 lines) owns two layers:
1. **Type-level comparators** — five `static` functions (`sim_verify_str_eq`, `sim_verify_alloc_eq`, `sim_verify_host_alloc_eq`, `sim_verify_job_alloc_eq`, `sim_verify_job_preason_eq`) that walk two libxce structs field-by-field and return only 0/1. They have zero dependency on the scheduler, capnp, or the replay layer. `sim_verify_alloc_eq` excludes `from` (bug-1838-followup) and `instid` (bug-1844). `sim_verify_job_preason_eq` compares only `main_reason` (bug-1845), a simulator replay asymmetry rather than an object-identity property.
2. **Decision-level dispatcher + reporting** — `sim_decision_eq` (dispatches on `xce_decision_t.op` vs `ReplayDecision_body_which`), `sim_log_mismatch`, and two `describe_*` helpers. These depend on `replay_decision_t`, capnp enums, and `ERROR()`. `sim_log_mismatch` prints only jobid/nhosts for each side, so a mismatch below that level is invisible (bug-1881).
The four allocation/pending-reason structs are defined in `src/include/xce/types.h` and already constructed/deep-copied by `src/lib/xcealloc.c` (`make_alloc`, `make_host_alloc`, `make_job_alloc`, `dup_job_alloc`) and `src/lib/preason.c` (`add_job_preason`, `make_pendreason`). The controller consumes these same structs in `persist.c`, `resync.c`, and `job.c` with no equality API available.
Since this design was first drafted, bug-1863 (`aea34738`) added `merge_job_alloc()` to `xcealloc.c` with a private `same_alloc_slot()` helper that matches two `xce_alloc_t` records by `resname`, `instid`, and `reserve`, to decide whether they are the same reservation slot to merge. It is a near neighbour of `alloc_eq` but has a different contract (see Decision 6).
**How `from` is set today.** `make_alloc()` (`xcealloc.c`) stamps `from = time(NULL)`. The scheduler's only production constructor call is in `sched_make_allocs()` (`scheduler.c`), called by the seqjob/parjob allocators' `my_alloc()` inside a scheduling session. `from` is on the wire (`controller.capnp` `from @4: UInt64 # alloc start time`) and `dup_alloc()` copies it. Neither the controller nor the worker reads an allocation's `from` today.
**Which core `sched_make_allocs()` sees.** Its first argument is `plugin->sched`. In normal mode that is the real scheduler, whose `core` runs the sessions. In simulation it is **not**: `sim_alloc.c` (`sim_bootstrap_seqjob_allocator()`/`..._parjob_...`) hands the allocators a throwaway `xce_scheduler_t` wrapper with its own `xce_sched_core_init()`'d core, aliasing only `resmgr`; that core never begins a session, so its `session_start` is 0. The honest session core is the `core` argument threaded through `sched_make_allocation()` → `sched_make_primary_alloc_(core, ctx)` (`sim->core` in simulation, `sched->core` in normal mode). Every allocation — PRIMARY, each primary of a COMPOUND, each alternative of an ALTER — is produced by one `sched_make_primary_alloc_()` call.
**How session time is read today.** Two separate clock reads per session:
- `xce_core_begin_session()` sets `core->session_start = xce_core_now(core)` (`core_session.c:429`).
- The recording then reads the clock again for `beginRound`: `driver.c:1030` after `plugin_begin_session`/`sched_account_jobs`, and `sim_loop.c` before `xce_core_begin_session`.
In simulation, `sim_advance_virtual_clock(sim, begin_round)` re-anchors the virtual clock to the recorded `beginRound`. At `--sim-speed` ≥ 1 the clock keeps advancing from there, so `session_start` lands slightly after `begin_round`. At `--sim-speed 0` it is frozen and exact. The simulator never sleeps, so `--sim-speed` paces nothing; it only adds drift. Spec `scheduler-sim-discrete-clock` removes it, which makes `session_start == begin_round` in every replay.
Three scheduler test files already reuse two of the leaf comparators — but via white-box `#include "../src/sim_verify.c"` + direct calls to the `static` functions: `test_sim_equivalence.c` (lines 894, 902), `test_repl_equivalence.c` (lines 734, 742), and `test_repl_limit_table.c` (lines 2662, 2670).
### Architecture Pattern & Boundary Map
```mermaid
flowchart LR
subgraph scheduler
SV["sim_verify.c\n(sim_decision_eq, sim_log_mismatch, describe_*,\nmain-reason-only PEND outcome check)"]
DR["driver.c\nbeginRound = core.session_start"]
SC["scheduler.c\nalloc->from = session_start / 1e9"]
CS["core_session.c\nsession_start"]
DC["spec scheduler-sim-discrete-clock\nreplayed session_start == recorded beginRound"]
TE["test_sim_equivalence.c / test_repl_equivalence.c / test_repl_limit_table.c"]
TSV["test_sim_verify.c"]
end
subgraph libxce
XA["xcealloc.{c,h}\nalloc_eq / host_alloc_eq / job_alloc_eq (+why)"]
PR["preason.{c,h}\njob_preason_eq (full match, +why)"]
UT["utils.{c,h}\nstr_eq (NULL-safe)"]
end
SV --> XA
TE --> XA
TE --> PR
XA --> UT
PR --> UT
DR --> CS
SC --> CS
DC -.-> CS
```
- Domain boundary: the library owns *struct-level* equality and its explanation (full structural match); the simulator owns *decision-level* comparison and reporting, including its one deliberate loosening (main-reason-only PEND). The scheduler core owns the session-start value that makes `from` reproducible.
- Steering compliance: no changes to `types.h` layouts or the wire schema; `from` keeps its unit and meaning ("alloc start time"), now defined as the start of the deciding session. Persistence: `from` already round-trips through the snapshot (`dup_job_alloc`) and the controller's capnp encoding, so no new save/restore field is introduced.
### Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|-------|------------------|-----------------|-------|
| Language | C (gnu17) | equality functions, `from` stamping, tests | unchanged |
| Build | Meson via `xce-build` | compile libxce + scheduler, run `src/lib/testsuite` and `src/scheduler/testsuite` | unchanged |
| Test | Unity | `test_xcealloc.c`, `test_preason.c`, scheduler tests | existing harnesses |
## File Structure Plan
### Modified Files
- `src/lib/utils.c` / `src/include/utils.h` — add a NULL-safe `str_eq(const char*, const char*)` helper (see Decision 5).
- `src/lib/xcealloc.c` / `src/include/xcealloc.h` — add `alloc_eq`, `host_alloc_eq`, `job_alloc_eq` with the optional `char** why` description.
- `src/lib/preason.c` / `src/include/preason.h` — add `job_preason_eq` (full match) with the optional description.
- `src/scheduler/src/driver.c` — record `beginRound` from `sched->core.session_start` instead of a second `xce_core_now()` read.
- `src/scheduler/src/scheduler.c` — in `sched_make_primary_alloc_(core, ctx)`, when the allocator returns `rc > 0` with `ctx->alloc != NULL`, set `from = core->session_start / 1000000000ULL` on every `xce_alloc_t` in `ctx->alloc` (per-host and shared). Not in `sched_make_allocs()`: in simulation its `sched` is `sim_alloc.c`'s wrapper, whose core has no session.
- `src/scheduler/src/sim_verify.c` — delete the five `static` comparators; `sim_decision_eq` gains an optional `char** why` and calls `job_alloc_eq` for DISPATCH; keep a simulator-local `static` main-reason-only helper for PEND; `sim_log_mismatch` gains a `const char* why` parameter and appends it. Update the header comments that describe `from` as excluded.
- `src/scheduler/src/sim_loop.c` — update both `extern` declarations; in the per-decision loop call `sim_decision_eq(expected, d, &why)`, pass `why` to `sim_log_mismatch`, then `free(why)`; the leftover-recorded loop (recorded decisions with no live counterpart) passes the string literal `"no live decision"`, which it does not free.
- `src/lib/testsuite/test_xcealloc.c`, `src/lib/testsuite/test_preason.c` — unit tests.
- `src/scheduler/testsuite/test_sim_equivalence.c`, `test_repl_equivalence.c`, `test_repl_limit_table.c` — replace direct calls with `job_alloc_eq(..., NULL)`/`job_preason_eq(..., NULL)` (or take the description, print it on failure, and free it); add `#include "xcealloc.h"`/`#include "preason.h"`; comment at each `job_preason_eq` call why full match is valid (Decision 7); keep the white-box `#include "../src/sim_verify.c"`.
- `src/scheduler/testsuite/test_sim_verify.c` — add the `why` argument (`NULL`) to its 16 direct `sim_decision_eq()` calls; drop the DISPATCH alloc-level tests now owned by libxce; invert `test_dispatch_decisions_differing_from_timestamp_still_match` (a `from` difference is now a mismatch, and `why` names `from`); add a test that the mismatch description names the differing field; keep PEND/decision-level and integration tests.
- Hand-built recorded DISPATCH fixtures that `--sim-verify` or an equivalence test compares against a live replay — they build the recorded allocation with `make_alloc()` (`from = time(NULL)`) or a literal `from`, which no longer matches the replay's `session_start / 1e9`: set the recorded `from` to the fixture session's `begin_round / 1000000000`. Audit every test with a hand-built `ReplayDecision_body_dispatch`: `test_sim_live_allocation.c` (the `make_alloc()` fixture near line 726, `begin_round = 2000` → `from = 0`), `test_sim_contention_parity.c`, `test_sim_resource_contention.c`, `test_sim_round_boundary_reconciliation.c`, `test_sim_reconcile_unaccounted.c`, `test_sim_verify.c`; fixtures that are never compared against a live allocation need no change.
- A scheduler test (new or in an existing session/allocation test file) — allocations made in one session carry `session_start / 1e9` as `from`, including in simulation (the `sim_alloc.c` wrapper path), and the recorded `beginRound` equals `session_start`.
- `src/scheduler/testsuite/test_sim_live_allocation.c`, `test_sim_reconcile_unaccounted.c`, and the header comments of `test_sim_equivalence.c`/`test_repl_equivalence.c` — update comments that name `sim_verify_*_eq` or describe `from` as excluded.
## System Flows
```mermaid
flowchart TD
A["sim_decision_eq(expected, actual, why)"] --> B{"actual->op"}
B -->|DISPATCH| C["job_alloc_eq(expected alloc, actual alloc, why)"]
B -->|PEND| D["jobid equal AND main_reason equal (sim-local, NOT job_preason_eq); why names which"]
B -->|PREEMPT| E["log + report mismatch (unchanged)"]
C --> F["host_alloc_eq per host"] --> G["alloc_eq per resource (incl. from, excl. instid)"]
D --> H["main-reason only, detail ignored (bug-1845)"]
C --> M["sim_loop: sim_log_mismatch(..., why); free(why)"]
D --> M
```
`from` stamping per session:
```mermaid
sequenceDiagram
participant Drv as driver.c
participant Core as core_session.c
participant Sch as scheduler.c sched_make_primary_alloc_(core, ctx)
participant Rec as recording
Drv->>Core: xce_core_begin_session()
Core-->>Core: session_start = xce_core_now()<br/>(replay: == recorded beginRound, via scheduler-sim-discrete-clock)
Drv->>Rec: beginRound = session_start
Sch->>Sch: allocator builds ctx->alloc (make_alloc: from = time(NULL))
Sch->>Sch: every alloc in ctx->alloc: from = core->session_start / 1e9
```
## Requirements Traceability
| Requirement | Summary | Components | Interfaces |
|-------------|---------|------------|------------|
| 1.1–1.3 | allocation equality for `xce_alloc_t`/`xce_host_alloc_t`/`xce_job_alloc_t` | `xcealloc.c` | `alloc_eq`, `host_alloc_eq`, `job_alloc_eq` |
| 1.4 | exclude `instid` only; document difference from `merge_job_alloc` | `xcealloc.c/.h` | `alloc_eq` field list + header comment |
| 1.5–1.7 | NULL handling, malformed-record precondition, read-only | all four functions | const params |
| 2.1–2.3 | pending-reason full structural equality | `preason.c` | `job_preason_eq` |
| 3.1–3.6 | first-difference description, caller-owned | all four functions | `char** why` |
| 4.1–4.2, 4.4–4.5 | reproducible `from` | `driver.c`, `scheduler.c` `sched_make_primary_alloc_()` | `core->session_start` |
| 4.3 | replayed session start == recorded `beginRound` | spec `scheduler-sim-discrete-clock` | — |
| 5.1 | simulator uses libxce allocation comparison | `sim_verify.c` | `job_alloc_eq` |
| 5.2 | simulator-local main-reason-only PEND check | `sim_verify.c` | local static helper |
| 5.3 | mismatch log names the difference (bug-1881) | `sim_verify.c`, `sim_loop.c` | `sim_decision_eq(..., why)`, `sim_log_mismatch(..., why)` |
| 5.4 | zero-mismatch self-replay | integration tests | — |
| 6.1–6.3 | single shared implementation; test files use full-match preason; include stays | `sim_verify.c` + 3 test files | public API |
| 6.4–6.6 | libxce + scheduler tests, no other behavior change | test files | — |
| 6.7 | stale comment references updated | scheduler sources/tests | — |
## Components and Interfaces
### libxce
#### Allocation equality (`xcealloc.c`)
| Field | Detail |
|-------|--------|
| Intent | Public structural equality for the allocation structs, with an optional first-difference description |
| Requirements | 1.1–1.7, 3.1–3.6 |
```c
/* src/include/xcealloc.h */
extern int alloc_eq(const xce_alloc_t* a, const xce_alloc_t* b, char** why);
extern int host_alloc_eq(const xce_host_alloc_t* a, const xce_host_alloc_t* b,
char** why);
extern int job_alloc_eq(const xce_job_alloc_t* a, const xce_job_alloc_t* b,
char** why);
```
- **Preconditions**: a non-zero entry count implies a non-NULL entry array (a malformed record is not handled). NULL top-level arguments, NULL list entries, and NULL string fields are handled. `why` may be NULL; `*why` on entry is ignored (never read or freed).
- **Postconditions**: returns non-zero iff the two arguments are equal per the documented contract; does not mutate either argument. If `why != NULL`: on equal, `*why = NULL`; on not-equal, `*why` is a `malloc`'d, NUL-terminated description of the first difference (see Data Models), sized by the function and never truncated; the caller owns it and releases it with `free()`. If that allocation fails, `*why = NULL` and the return value is unaffected.
- **Invariants**: `alloc_eq(a, b)` answers "same allocation decision", compares `from`, and ignores `instid`; the header comment states that this differs from `merge_job_alloc()`'s slot matching, where `instid` is significant; `host_alloc_eq`/`job_alloc_eq` are order-sensitive (both sides come from the same deterministic construction, so array order is expected to match, not just multiset). The result never depends on `why` or on whether the description could be allocated.
#### Pending-reason equality (`preason.c`)
| Field | Detail |
|-------|--------|
| Intent | Public full structural equality for `xce_job_preason_t`, with an optional first-difference description |
| Requirements | 2.1–2.3, 3.1–3.6 |
```c
/* src/include/preason.h */
extern int job_preason_eq(const xce_job_preason_t* a,
const xce_job_preason_t* b,
char** why);
```
- **Postconditions**: returns non-zero iff `main_reason` matches, the host-based reason list matches in order (each entry by host name + reason), and the resource-based reason list matches in order (each entry by resource name + host name + reason); NULL-safe per the shared convention; `why` as for the allocation functions.
- **Invariants**: this is a full match — it is the honest "are these two pending reasons identical" predicate, not the simulator's replay loosening (see Decision 3).
#### Shared string helper (`utils.c`)
```c
/* src/include/utils.h */
extern int str_eq(const char* a, const char* b);
```
- **Postconditions**: returns non-zero iff `a` and `b` are both NULL, or both non-NULL and `strcmp(a, b) == 0`; zero otherwise.
### Scheduler
#### Recording (`driver.c`)
- Normal mode: `sched_record_session_begin(sched, sched->core.session_start)` replaces the separate `xce_core_now()` read, so the recorded `beginRound` is the session start.
- Simulation: no change here. Spec `scheduler-sim-discrete-clock` guarantees `core->session_start == begin_round` for a replayed session (clock frozen between recorded timestamps).
#### `from` stamping (`scheduler.c`)
- In `sched_make_primary_alloc_(core, ctx)`, after the allocator returns `rc > 0`: a small `static` helper walks `ctx->alloc` (every `allocs[i]->allocs[j]` and every `allocs_shared[k]`, skipping NULL entries) and sets `from = core->session_start / 1000000000ULL` (seconds, floor). A COMPOUND's primaries are each stamped before `merge_job_alloc()` folds them, and all carry the same session value, so the merged allocation is consistent.
- Why here and not in `sched_make_allocs()`: `core` is the session core in both modes; `sched_make_allocs()`'s `sched` is `sim_alloc.c`'s session-less wrapper in simulation.
#### Simulator verification (`sim_verify.c`)
- `int sim_decision_eq(const replay_decision_t* expected, const xce_decision_t* actual, char** why)`: DISPATCH calls `job_alloc_eq(..., why)`; PEND sets `*why` to `jobid A != B` or `main_reason A != B`; kind mismatch sets `kind DISPATCH != PEND`; a NULL side sets `no recorded decision at this index` / `no live decision`; PREEMPT sets `PREEMPT comparison not supported`. Same ownership contract as the libxce functions.
- `void sim_log_mismatch(uint64_t begin_round, int decision_index, const replay_decision_t* expected, const xce_decision_t* actual, const char* why)`: appends ` -- diff: <why>` to its existing line when `why != NULL`, otherwise the line is unchanged. It does not compare again and does not free `why`; `sim_loop.c`, which already calls `sim_decision_eq()` right before it, owns and frees the string.
## Data Models
No struct is added or modified. The equality contract and the description format are the only new "models".
- `alloc_eq` compares, in order: `resname`, `reserve`, `value` (exact `==`, documented bit-exact-safe because both sides are exact copies of the same `double`), `duration`, `from`. **Excluded**: `instid` (per-process `nextid++` counter).
- `host_alloc_eq` compares: `host`, then `nallocs`, then `allocs[i]` in order.
- `job_alloc_eq` compares: `jobid`, `nhosts`, `nres_shared`, then `allocs[i]` in order, then `allocs_shared[i]` in order.
- `job_preason_eq` compares: `main_reason`; then `nhost_reasons` and `host_based_reasons[i]` in order, each entry by `host` and `reason`; then `nres_reasons` and `res_based_reasons[i]` in order, each entry by `resource`, `host`, and `reason`. **Nothing is excluded.**
- **Description format** (first difference only, path from the outermost struct compared):
- scalar: `allocs[1](host=h1).allocs[0](resname=mem).value: 2 != 3`
- string: `allocs[0](host=h0).host: "h0" != "h2"`
- NULL: `allocs_shared[0]: NULL != non-NULL`
- length: `nhosts: 1 != 2`
- path labels (`host=`, `resname=`) are taken from the first argument (`a`), or from `b` when `a`'s entry or string is NULL, or `?` when both are.
- `jobid` formatted with libxce's own `fmt_jobid()` (`utils.h`), so it reads like the rest of the scheduler log; `from` as integer seconds; `value` with `%.17g` so an exact-compare difference is always visible.
## Error Handling
These are read-only functions with no I/O and no failure path for the comparison itself. The only allocation is the optional description; if it fails, `*why` is NULL and the result is unchanged (`vasprintf` leaves its output undefined on failure, so the implementation sets `*why = NULL` explicitly). A nested call's description is re-wrapped with the parent's path segment and the inner string freed. The only "error" case is NULL input, which is handled by the shared convention (identical pointers including both-NULL → equal; exactly one NULL → not-equal) rather than an error return. A malformed record (non-zero count, NULL array) is a documented precondition violation, not a handled case. No logging is added; the description is built with `vasprintf` (glibc; `_GNU_SOURCE` defined at the top of `xcealloc.c`/`preason.c`) and returned to the caller.
## Testing Strategy
- **Unit (libxce)**: `test_xcealloc.c` — match/mismatch on each compared field with its expected description, `instid` ignored, `from` compared, ordered-list sensitivity, list-length mismatch, NULL handling at top level, nested entries, and string fields, NULL `why`, `*why == NULL` on equal, a failed description allocation (`--wrap` on `vasprintf` or `malloc` in a dedicated test binary: `*why` NULL, same result); every test frees the returned description, so ASAN/LeakSanitizer proves ownership; an existing `merge_job_alloc` test still passes. `test_preason.c` — full-match: main-reason, host-based and resource-based lists per field + order + length, NULL handling, description cases; plus `str_eq` NULL/NULL and NULL/non-NULL.
- **Unit (scheduler)**: `test_sim_verify.c` keeps `sim_decision_eq`-level tests (DISPATCH/PEND match and mismatch, kind mismatch, PREEMPT always-false, NULL defense), asserts the description for each, and drops the alloc-level tests now owned by libxce. A session-level test asserts every allocation of one session (including a COMPOUND merge and the simulation wrapper path) carries `session_start / 1e9`, and that the recorded `beginRound` equals `session_start`.
- **Integration (scheduler)**: `test_sim_verify.c`'s `--sim-verify` round-trips (zero-mismatch self-replay, exact-mismatch altered replay, reconcile cases); the self-replay reports zero mismatches. These tests, and `test_sim_equivalence.c`/`test_repl_equivalence.c`/`test_repl_limit_table.c`/`test_sim_live_allocation.c`, already run the simulator at `sim_speed = 0` (clock frozen), so `session_start == begin_round` holds for them before `scheduler-sim-discrete-clock` lands; only the live `--sim-verify` check at the default speed waits for it.
- **Regression**: `test_sim_equivalence.c`, `test_repl_equivalence.c`, `test_repl_limit_table.c` stay green under the stricter checks (full-match preason, `from` compared); the hand-built recorded DISPATCH fixtures listed in the File Structure Plan are updated to carry the session's `from`. A failure after that is investigated as a real divergence.
- **Live**: record a session on a local cluster, replay it with `--sim-verify` at the default speed: zero mismatches.
## Design Decisions
### Decision 1: Co-locate equality with construction
- **Context**: where to put the four functions.
- **Alternatives**: a single new `xce_equality.{c,h}` module; or one function per struct's existing owner module.
- **Selected**: co-locate — `alloc` family in `xcealloc.c`, `job_preason_eq` in `preason.c`.
- **Rationale**: matches the existing precedent (`dup_job_alloc` lives beside `make_*` in `xcealloc.c`; `add_job_preason`/`make_pendreason` in `preason.c`); avoids a new module for four small functions.
- **Trade-offs**: none material.
### Decision 2: Naming (`alloc_eq`, not `xce_alloc_eq`)
- **Context**: public API names.
- **Alternatives**: `xce_`-prefixed names; `_equals` suffix.
- **Selected**: `alloc_eq`/`host_alloc_eq`/`job_alloc_eq`/`job_preason_eq`.
- **Rationale**: follows the existing unprefixed lowercase convention (`make_alloc`, `dup_job_alloc`, `erase_job_preason`), with the `_eq` suffix as the standard C equality idiom. The `job_alloc`/`job_preason` stems match the struct names.
- **Trade-offs**: `alloc_eq` is slightly generic, but it is unambiguous in a header (`xcealloc.h`) that already owns the unprefixed `alloc` family.
### Decision 3: `job_preason_eq` is a full structural match; the simulator keeps its own main-reason-only PEND check
- **Context**: the current `sim_verify_job_preason_eq` compares only `main_reason`, deliberately (bug-1845): the simulator has no alloc cache, so its full-detail PEND can never match a recorded cache-hit's main-reason-only shape, and requiring detail to match produced systematic false mismatches. That narrowing is a property of *recorded-vs-live replay comparison*, not of "are these two pending-reason objects identical".
- **Alternatives**: (a) publish a full-deep `job_preason_eq` and keep the main-reason-only comparison as a simulator-local static helper (selected); (b) publish main-reason-only `job_preason_eq` (rejected — leaks a replay artifact into a general-purpose equality API and contradicts the identity semantics a controller engineer would expect).
- **Selected**: `job_preason_eq(a, b, why)` returns non-zero iff `main_reason` matches and both detail lists match in order, field-for-field. The simulator's `sim_decision_eq` PEND branch keeps a small `static` main-reason-only helper (documented with the bug-1845 rationale) instead of calling `job_preason_eq`.
- **Rationale**: a public "same pending reason" predicate must be identity-honest — a controller comparing two pending reasons will want the detail to matter. The replay asymmetry is a scheduler-only concern and stays where it belongs.
- **Trade-offs**: two PEND-comparison shapes now exist (full-match in libxce, main-reason-only in sim_verify.c). This is intentional: they answer different questions ("identical objects" vs. "same replay outcome"). The sim-local helper is `static` and small, so the duplication is negligible and clearly scoped.
### Decision 4: Preserve NULL and order semantics
- **Context**: NULL handling and list-order sensitivity.
- **Selected**: keep today's behavior — identical pointer (including both NULL) → equal; exactly one NULL → not-equal; ordered (not multiset) list comparison. A malformed record (non-zero count, NULL array) is not handled — it is a documented precondition, matching today's comparators.
- **Rationale**: both sides of every current comparison come from the same deterministic construction, so order-sensitivity is correct. The new full-match `job_preason_eq` uses the same convention.
### Decision 5: Promote the NULL-safe string comparison to a shared `str_eq` in `utils.{c,h}`
- **Context**: with `job_preason_eq` now a full match, both `xcealloc.c` (host/resname/reserve strings) and `preason.c` (host/resource strings) need NULL-safe string comparison. Keeping it `static` in one file forces the other to duplicate it.
- **Alternatives**: (a) shared `str_eq` in `utils.{c,h}` (selected); (b) duplicate a `static` helper in each of the two files.
- **Selected**: add `int str_eq(const char* a, const char* b)` to `utils.{c,h}`, beside the existing `float_equal` helper.
- **Rationale**: two consumers and a third on the horizon (controller) justify a shared helper. `utils.h` already centralizes small generic C helpers (`fmt_jobid`, `float_equal`).
- **Trade-offs**: a new public symbol in `utils.h`; negligible surface.
### Decision 6: `alloc_eq` and `same_alloc_slot` stay separate
- **Context**: bug-1863 added a private `same_alloc_slot(a, b)` to `xcealloc.c` (`resname` + `instid` + `reserve`), used by `merge_job_alloc()` to decide whether a source allocation should be added into an existing destination entry. `alloc_eq` will live in the same file and also compares `xce_alloc_t` records.
- **Alternatives**: (a) keep them separate and document the difference (selected); (b) implement `same_alloc_slot` on top of `alloc_eq` or vice versa.
- **Selected**: keep them separate. `alloc_eq` answers "do these two records describe the same allocation decision" (resname, reserve, value, duration, from; `instid` excluded). `same_alloc_slot` answers "are these the same reservation slot to accumulate into" (resname, instid, reserve; `value` excluded because merging adds values). The `alloc_eq` header comment names the difference.
- **Rationale**: the two predicates disagree on `instid`, `value`, `duration`, and `from`, so neither can be expressed through the other without changing behavior. `merge_job_alloc()` is out of scope.
- **Follow-up**: none. bug-1882 (a NULL `resname` reaching `same_alloc_slot()`'s `strcmp()` after a failed `strdup()`) is fixed in f9146864: `make_alloc()`/`make_host_alloc()` no longer return records with NULL strings.
### Decision 7: The three test files adopt the full-match `job_preason_eq`
- **Context**: `test_sim_equivalence.c`, `test_repl_equivalence.c`, and `test_repl_limit_table.c` compare two decoded `replay_decision_t` entries and today call the main-reason-only `sim_verify_job_preason_eq`.
- **Alternatives**: (a) switch them to the full-match `job_preason_eq` (selected); (b) give them their own main-reason-only helper.
- **Selected**: (a). None of these tests registers `unsatisfiable_lookup_cb` (the alloc cache), so both sides of every pending-reason comparison are produced by the same full per-host computation and their detail lists are expected to match. The bug-1845 asymmetry (recorded cache-hit vs. live full detail) does not apply.
- **Rationale**: the stricter check catches detail divergence the old one missed, at no false-positive cost for these fixtures. Each call site gets a short comment stating why full match is valid there.
- **Trade-offs**: if a future test records with the alloc cache active, it must use a main-reason-only comparison instead; the call-site comment and the Revalidation Trigger on pending-reason construction paths cover this.
### Decision 8: `from` is the deciding session's start, from one clock reading
- **Context**: `alloc_eq` must compare `from`, but `make_alloc()` stamps `time(NULL)`, which a replay cannot reproduce (bug-1838-followup). The scheduler already has a virtualized session start (`core->session_start`), but it is a different clock read from the recorded `beginRound`, and in simulation at `--sim-speed` ≥ 1 it drifts past the recorded `beginRound`.
- **Alternatives**: (a) `from = session_start / 1e9`, with the recorded `beginRound` taken from `session_start`; the simulator clock stops drifting (spec `scheduler-sim-discrete-clock`) so a replayed `session_start` is exactly the recorded `beginRound` (selected); (a′) as (a), but with an explicit `xce_core_begin_session_at(core, begin_round)` in the simulator instead of removing the drift (superseded: the user chose to remove `--sim-speed`, which fixes the drift for every clock reader, not just `from`); (b) keep excluding `from` from equality (rejected by the user: `from` is part of what was decided); (c) `from = xce_core_now()` at allocation time (rejected: at `--sim-speed` ≥ 1 it drifts with replay pace, and a second-boundary crossing produces a false mismatch); (d) change `make_alloc()`'s signature to take `from` (rejected: churns the controller and ~20 test callers for no benefit; the scheduler is the only producer that needs a different value).
- **Selected**: (a). The recording's `beginRound` is taken from `core->session_start`; `sched_make_primary_alloc_(core, ctx)` overwrites `from` on the allocator's result using the session core (not `sched_make_allocs()`, whose `sched` is a session-less wrapper in simulation); the simulator side comes from `scheduler-sim-discrete-clock`.
- **Rationale**: every allocation of one session gets the same `from`, which a replay reproduces bit-for-bit. `from` changes meaning only slightly — from "second the record was built" to "second the deciding session started", normally the same second and at most one session's duration earlier.
- **Trade-offs**: hand-built test fixtures that stand in for a recording must now carry the session's `from`. Recorded `beginRound` moves a few milliseconds earlier (to `xce_core_begin_session`, before `plugin_begin_session`/`sched_account_jobs`). Recordings made before this change carry wall-clock `from` values and may show a false DISPATCH mismatch when an allocation's `time(NULL)` fell in a later second than its session start; the difference description names `from`, so these are recognisable. `make_alloc()` keeps `time(NULL)` for its other callers.
### Decision 9: First-difference description returned through `char** why`, owned by the caller
- **Context**: a yes/no comparator leaves a mismatch undiagnosable (bug-1881).
- **Alternatives**: (a) optional `char** why`: the function allocates a string sized to fit and hands ownership to the caller (selected, per the user); (b) caller buffer `char* why, size_t whylen` (superseded: the caller must guess a size, and a long path through many hosts gets truncated); (c) a separate `*_diff()` function family; (d) a list of every difference; (e) a callback per difference.
- **Selected**: (a). One code path computes both the result and the explanation, so they cannot disagree; callers that do not care pass `NULL`. On not-equal the function sets `*why` to a `vasprintf`'d string; on equal, or if the allocation fails, `*why = NULL`. The caller frees with `free()`.
- **Rationale**: the function knows the length; the caller only decides whether to ask. Allocation happens only on the not-equal path with `why` requested, i.e. only when something is about to be reported, so the equal fast path stays allocation-free. The first difference is the one that matters for a deterministic replay; later ones usually follow from it.
- **Trade-offs**: every caller that asks must free (ASAN/LeakSanitizer in the test suites catches a missed `free`); only the first difference is reported.