Skip to content

Phase 8.2 WP1: freeze the worker protocol and add the Python decode-baseline workers - #87

Merged
perNyfelt merged 6 commits into
mainfrom
phase8-2-wp1
Nov 7, 2026
Merged

perNyfelt merged 6 commits into
mainfrom
phase8-2-wp1

Conversation

@perNyfelt

@perNyfelt perNyfelt commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Phase 8.2 WP1 (plan req/plans/phase8-2-plan.md), part 1 of 2: the frozen
worker protocol and the Python side of the benchmark workers. The Java side
(launcher, statistics/validation, jmlx-models test-source adapters,
tiny-fixture gate tests, CI required-suite list) lands on this branch before
the review cycle.

What this PR contains

  1. Frozen contract — findings §13 (req/plans/phase8-2-reference-findings.md)

    • jmlx-decode-baseline-job-1 job schema (launcher → worker) with every
      validation rule; worker protocol (launcher-created unique out_dir,
      worker-ready handshake marker, atomic single-write report.json,
      exit codes 0/1/3, 1 s-poll watchdog with 30 s grace backstop,
      overwrite rejection, per-request priming).
    • Final jmlx-decode-baseline-1 record shapes: per-worker report with the
      identity block moved out of the session (the launcher-assembled session
      report embeds worker reports and keeps host/fork-order/native-pin
      fields), per-request event_s/tokens, fixed-continuation
      argmax_id/top2_margin steps, and the explicit §10 amendments.
    • Verified 2026-11-06 facts, including two corrections/refinements:
      mlx_lm.load() merges generation_config.json's eos into the wrapper
      (all three text cases already resolve to exactly the manifest stop sets;
      the worker still completes + asserts defensively), and controlled-vs-
      stock greedy streams are not a cross-adapter invariant (divergence at
      token 103 on the frozen random 0.6b prompt, margin 0.125; the §2.4
      logit-level identity claim is unaffected) — fixed-continuation argmax
      agreement holds with zero mismatches over 26 × 32 steps per adapter.
  2. Python workers (tools/decode-baseline/worker.py, controlled.py)
    implementing the frozen §6 adapter contracts: the controlled synchronous
    loop (single full-prompt forward, last-position own-head projection,
    exactly 127 cached steps, no lookahead/async/cache-clear, evaluated state
    at every step boundary) and the stock path (shipped stream_generate
    unmodified, stop-set completion via the shipped add_eos_token API with
    equality assertion, explicit mx.synchronize() drain with the unused
    lookahead forward counted). Headline, fixed-continuation and
    reference-pair modes; MLX_ENABLE_TF32 set before any mlx import;
    venv pins and the full installed mlx_lm source freeze (all 202 pinned
    files) re-verified per run.

  3. Manifest re-pin + guard extension

    • workloads.json gains run_policy (the frozen §7.4 finite limits,
      including first_requests: 1) and worker_sources (SHA-256 of both
      workers, path-confined); new frozen digest 23271da6... (round-1
      re-pin), cited in §2 and pinned by the guard.
    • verifyDecodeBaselineManifest (root check) gains structural checks
      for both blocks plus the two worker files as task inputs. Negative
      tampers executed: run-policy-limit, run-policy-regime,
      io-affected-cells, worker-source-digest, worker-file-edit and
      worker-file-set each fail with a specific error; restore verified
      byte-identical (§2.3).

Evidence (this host, M5 Max — contract verification, no headline measurement)

  • Full-policy smoke (1 first + 5 warmups + 20 measured) per adapter on the
    pinned 0.6b artifact: headline 128 tokens / 127 steps (positions
    L+1…L+127), stock unused_lookahead_forwards=1 with finish length;
    fixed-continuation L=512: 32 fed steps, positions 513–544, zero argmax
    mismatches between the two adapters including exact-tie steps.
  • Validation failure with a launcher-created out_dir leaves a failed
    report + exit 1; verify.sh still VERIFIED; root ./gradlew check
    green; git diff --check clean.

Deliberately not in this PR

  • Java launcher/worker, schema/statistics/validation core, benchmark-only
    jmlx-models adapters and tiny-fixture gate tests — next commit(s) on
    this branch, implementing §13.
  • No real-model performance claims (WP1 gate), no downloads or Python
    execution in ordinary check beyond the pure-Java guard, and
    benchmarkDecode/benchmarkBatchDecode/benchmarkTf32 untouched.

Review round 1 (commit 3, head of this branch)

All seven findings and all four suggestions from the review of head
4ef0503 are addressed:

  1. High — a refused job destroyed the existing report. write_report
    now hard-links the temp file (FileExistsError instead of replace), so
    no path — validation failure, watchdog backstop or otherwise — can
    replace an existing report.json; the refusal (with the worker's reason)
    goes to stderr only. Verified: a pre-seeded report survives a refused job
    byte-identical (exit 1).
  2. Medium — a late last request reported ok. The fired deadline is
    re-checked after the final request. Verified by a temporary real-manifest
    tamper (full 26-request mix, 0.6b L=512): deadline inside the final
    request → status: timeout, exit 3, 26 complete records; deadline inside
    request 25 → in-loop break, 25 records. Manifest restored byte-identical.
  3. Medium — unpinned safetensors would be loaded. The artifact's
    *.safetensors set must equal the case's pinned set exactly; a stray
    model-extra.safetensors in a cloned artifact dir is now rejected.
  4. Low — fixed-continuation step timing differed between adapters. Both
    paths stop the step timer at the evaluated logits (argmax + full-vocab
    sort outside the interval), and full_generation_s is the monotonic wall
    time from entry in both.
  5. Low — controlled token 1's event time preceded its argmax. Now taken
    after it, like every later token.
  6. Low — requests.first unchecked. run_policy.first_requests: 1 is
    frozen (manifest + guard + worker, outside reference-pair mode) and added
    to the identity block; a first: 2 job is rejected.
  7. Low — watchdog/main report race. write_report is
    lock-guarded; the backstop skips the status overwrite once the report is
    written (the exit stays unconditional).
  8. Suggestions, all adopted: manifest_path must resolve to the
    canonical repository workloads.json (a matching copy is rejected); the
    full 202-file installed mlx_lm source freeze is re-hashed per run (not
    just generate.py); the worker directory is appended to sys.path
    (not prepended); position is documented as "1-based position of the fed
    token — the cache length after the step" (findings §13.3, controlled.py).

The round-1 timeout probe also caught a real regression introduced by the
round-1 watchdog rewrite itself (the fired-event set was unreachable, so no
timeout could ever fire) — fixed and covered by the T2 evidence above.
Manifest re-pinned (run_policy.first_requests + worker_sources; new
digest 23271da6... in §2 and the guard backstop); guard tampers re-executed
(including new run-policy-first-requests and note-drift against the new
backstop) with byte-identical restore; full smoke re-run and
verifyDecodeBaselineManifest + root check + verify.sh green on the new
head.

Review round 2 (commit 4, head of this branch)

All three findings from the review of head a2f580d are addressed:

  1. Medium — the report recorded the wrong code version (reproduced:
    a full 0.6b job with repo_dir set to a throwaway git repo finished
    normally and reported that repo's commit as clean, while the code that
    actually ran was this checkout). repo_dir is now required to resolve
    to the repository the worker runs from, anchored on the worker file's
    own location — the same check manifest_path gets — so the report's
    code identity can never name a different checkout. Verified: a
    throwaway-repo_dir job is rejected (exit 1, failed report, empty
    identity.code).
  2. Low — fixed-continuation prefill still wasn't comparable between the
    adapters.
    The reviewer's second option was adopted: full_generation_s
    is now the step-region wall time (prefill excluded) in this mode — the
    comparable quantity, defined identically on both paths — and the stock
    prompt cache is created before the timer starts (symmetric with the
    controlled path). prefill_s is documented in §13.3 as per-adapter and
    not cross-adapter comparable: the stock shipped call projects the output
    head at every prompt position (about 155 MB of scores at L=512), the
    controlled prefill only at the last position.
  3. Low — report-writing weaknesses. All three fixed: the temp file
    name is per-process (report.json.tmp.<pid>), so a second process
    pointed at the same directory can no longer truncate or delete it
    mid-write; a success whose report link is refused is downgraded to
    failed (exit 1) — its results are not recorded, so the run must not
    present as success; a temp-write failure removes the temp before
    propagating (nothing left behind). Verified at the worker-module level
    (W1-W4: refusal leaves a pre-seeded report byte-identical with no temp;
    success returns True; second call returns False with the report
    unchanged; a write failure propagates with no temp and no report) and
    through the full smoke re-run.

Manifest re-pinned twice in this round (final digest 1c691642... in §2
and the guard backstop); guard tampers (8/8, byte-identical restore), the
worker negative suites (13 + 14 assertions) and the full smoke (4 workers,
25/25 cross-checks) re-run green on the final head. The T2 timeout probe
(temporary real-manifest tamper, full 26-request mix, 0.6b L=512,
self-correcting deadline placement) re-ran on the final head: in-loop break
at T=7 s (22 complete records, exit 3) on the fixed-continuation cell and
post-loop check at T=15 s (26 complete records, status: timeout, exit 3);
both exits via the main thread (wall < T + 15 s; the 30 s backstop would be
at ≥ T + 29 s); the manifest was restored byte-identical (SHA-256 verified)
and the guard re-passed. The probe also measured the fired lag on this
host: the fired event lands 0.5-1.4 s after the integer-second deadline
(the watchdog thread's first poll is delayed by the main thread's
mlx_lm import) — the 1 s-poll design bound is unchanged, and the probe's
placement self-corrects on top of it. verify.sh and root ./gradlew check
green on the final head.

Review round 3 (2026-11-07, head 94fea72)

Both findings addressed; contract changes and evidence in findings
§13.4 item 14.

  1. Low (reproduced) — a second worker that failed validation could take
    over a running worker's output folder.
    Each worker now claims the
    launcher-created out dir with an exclusive-create worker-claim file
    ({utc, pid}) before any validation
    : on an existing claim the worker
    writes nothing into the directory (no report, no temp file), reports
    failure_reason: output folder already claimed by another worker on
    stderr and exits 1. The claim is sticky — a killed worker leaves it, so
    a claimed directory without a report is dead and the launcher (Java
    side, PR Add the Ben Manes versions plugin and upgrade the build dependencies #88) must treat it as such. The report's worker block gains
    pid, so a launcher can verify the report came from the process it
    started; worker-ready is now created with exclusive create too; and
    the report-link refusal message is built from the final state and
    printed after the success downgrade, so it can no longer say "run
    ended as ok" for a run that was downgraded to failed. Verified by the
    new round-3 suite (26 assertions): R1 the reviewer's exact repro (valid
    fc worker A running; invalid job B, first: 0, same folder → B exits 1
    with no report/temp and the claim left byte-identical; A exits 0 with
    26 records and report pid == claim pid == ready pid); R2
    pre-seeded claim + invalid job → exit 1, nothing written, claim
    byte-identical; R3 pre-seeded worker-ready + valid job → exit 1 with
    the FileExistsError reason, failed report written (the folder was
    claimed by the run itself), ready byte-identical; R4 module-level
    (refusal sets the flag and prints nothing; a stale flag is reset at the
    call start; the message carries the post-downgrade final status).
  2. Suggestion — full_generation_s had two mode-dependent meanings.
    Fixed-continuation now records step_region_s (the step-region
    wall time, prefill excluded) with full_generation_s: null;
    headline/reference-pair keep full_generation_s (prefill included)
    with step_region_s: null — uniform key set, one value per mode.
    §13.3 states that decode comparisons use the per-step steps[].s
    values: the step region includes the per-step full-vocabulary
    sort-to-host/argmax check work (about 39-46% of the value on the 0.6b
    L=512 cell this round: 0.2178 s vs 0.1355 s sum of steps, controlled;
    0.2307 s vs 0.1248 s, stock), so differences in the region wall come
    from that host work, not from decoding.

Manifest re-pinned (digest 10eaaa0c... in §2 and the guard backstop);
guard tampers (8/8, byte-identical restore), the worker negative suites
(13 + 14 — the round-1/2 stderr assertions survive the post-downgrade
message) and the full smoke (4 workers, 30/30 cross-checks including the
new pid/claim and per-mode-key-set checks) re-run green on the final head.
The T2 timeout probe re-ran 11/11 and exposed a probe placement bug
(the worker was correct): the probe aimed the integer-second deadline as
if it sat at T in the utc_start origin, but the worker sets the
deadline right after the worker-ready marker (before the mlx_lm
import and model load), so it sits at ooff + T (ooff ≈ 0.58 s), and
the mid-run fired event lands essentially at the deadline (the watchdog
polls are phase-locked to the anchor; the 0.5-1.4 s first-poll lag of
item 13 applies only during the import). The headline MIX cell's
final-request window (~0.58 s) therefore contained no integer-second
deadline this session, so the round-2 T=15 pass was a host-speed phase
coincidence. The probe now aims in T-space and exercises the post-loop
branch on the reference-pair single-request cell (request starts at
~1.4 s; T=1 sits inside its window across normal host-speed variation —
same branch under test): in-loop break at T=7 s (22 complete records,
exit 3) and post-loop check at T=1 s (1 complete record,
status: timeout, exit 3), both via the main thread (wall < T + 15 s);
the manifest was restored byte-identical and the guard re-passed.
verify.sh VERIFIED and root ./gradlew check green on the final head.

Review round 4 (2026-11-07, head 8b1505f)

Both findings addressed; contract changes and evidence in findings
§13.4 item 15.

  1. Low (reproduced) — a missing output folder now failed with an
    unclear error.
    The round-3 claim is created before validation, so a
    missing out_dir (or one that is a file) raised FileNotFoundError
    from the claim create in the catch-all handler, and the old
    out_dir must be created by the launcher check in validate became
    dead code. Fixed: main now checks Path(raw_out_dir).is_dir()
    before claiming and raises the old message as a WorkerError
    (stderr worker validation failed: out_dir must be created by the launcher, exit 1, nothing written — there is no report target); the
    dead validate check is removed and the §13.2 claim bullet records
    the rejection. Verified by new negative tests: T8 (missing dir → exit
    1, the launcher message on stderr, no FileNotFoundError, nothing
    written) and T9 (file in the way → exit 1, same message, the file
    byte-identical, no report).
  2. Nit — two comments still called the step region "the comparable
    quantity."
    Both reworded to agree with the updated §13.3 (the step
    region is not decode time; decode comparisons use the per-step s
    values) — controlled.py's run_fixed_continuation docstring and the
    stock fixed-continuation branch in worker.py. The controlled.py
    docstring's value name was also renamed full_s → step_region_s to
    match the report field (the local variable renamed too). Since
    rewording controlled.py moves its pinned digest, this rides the
    round-4 re-pin rather than waiting for a later worker change.

Manifest re-pinned (digest e8c77301... in §2 and the guard backstop);
guard tampers (8/8, byte-identical restore), the worker negative suites
(20 + 14 — the 20 now include T8/T9), the round-3 suite (26) and the full
smoke (4 workers, 30/30 cross-checks) re-ran green on the final head. The
T2 timeout probe re-ran 11/11 — the corrected T-space aim landed the
in-loop break on the first attempt (T=8 s, 25 complete records, exit
3) and the post-loop check at T=1 s on the reference-pair cell (1
complete record, status: timeout, exit 3); both via the main thread
(wall < T + 15 s); the manifest was restored byte-identical and the guard
re-passed. verify.sh VERIFIED and root ./gradlew check green on the
final head.

Two digest-pinned workers under tools/decode-baseline/ implementing the
frozen adapter contracts (reference findings section 6):

- controlled.py: the synchronous controlled loop (single full-prompt
  forward, last-position own-head projection, exactly 127 cached steps,
  no lookahead/async/cache-clear, evaluated logits and cache state at
  every step boundary, first-maximum greedy tie-break) plus the
  fixed-continuation stepper with per-step argmax and top-2 margin.
- worker.py: the job-consuming entry point (schema
  jmlx-decode-baseline-job-1): full pre-load validation (manifest and
  file digests, venv pins, installed generate.py digest, canonical
  prompt digests, run_policy agreement, output-dir ownership),
  per-request priming (findings 7.3), headline/fixed-continuation/
  reference-pair modes for both adapters, the worker-ready handshake
  marker, a 1s-poll watchdog with 30s grace backstop (failed report
  plus exit 3, never a partial success), and the jmlx-decode-baseline-1
  report written atomically exactly once.

Verified on this host against the pinned venv and 0.6b artifact:
26 full-policy requests per adapter (128 tokens / 127 steps for
headline, 32 fed steps for fixed-continuation), zero argmax
mismatches between the two adapters on the fixed continuation, and
the stock unused_lookahead_forwards/drain contract.
- workloads.json: add the run_policy block (the frozen section 7.4
  finite limits, exact nonpositive-rejecting values) and the
  worker_sources digest pin for the two committed Python workers;
  new frozen digest 772753ac...
- build.gradle: verifyDecodeBaselineManifest gains structural checks
  for both blocks (frozen values, cache_regime, io_affected_cells,
  emitted_tokens cross-check; exact worker file set with path
  confinement and per-file SHA-256 re-hash) and the two worker files
  as task inputs; the digest backstop moves to the new value.
  Negative tampers: run-policy-limit, run-policy-regime,
  io-affected-cells, worker-source-digest, worker-file-edit and
  worker-file-set each fail with a specific error; restore verified
  byte-identical.
- findings: new section 13 freezes the launcher/worker job schema,
  output layout, report record shapes and boundary semantics
  (including the section 10 amendments), records the verified WP1
  facts (stop-set resolution correction, adapter-level greedy
  divergence vs the logit-level identity claim, fixed-continuation
  argmax agreement, Metal first-use warm-up, mlx API facts) and the
  worker re-pin flow; section 2/2.3 citations and the guard
  description updated to the new digest and checks.
…manifest

Addresses all seven findings and all four suggestions from the review of
head 4ef0503 (see req/plans/phase8-2-reference-findings.md section 13.4
items 9-12 for the contract changes and evidence):

- a refused job can no longer destroy an existing report (hard link
  instead of replace; refusal on stderr only)
- the fired deadline is re-checked after the final request
- the artifact safetensors set must equal the pinned set exactly
- run_policy.first_requests: 1 frozen in manifest, guard and worker
- the full 202-file installed mlx_lm source freeze is re-hashed per run
- manifest_path is pinned to the canonical repository file
- fixed-continuation step timers and full_generation_s are defined
  identically in both adapters; controlled token 1 event time taken
  after its argmax; the worker directory is appended to sys.path
- the report write is lock-guarded and the watchdog backstop skips the
  status overwrite once the report is written

The round-1 timeout probe caught and fixed a watchdog rewrite regression
(fired-event set unreachable, so no timeout could ever fire) and verified
both the in-loop and post-loop timeout paths. Manifest re-pinned (new
digest 23271da6...), guard backstop updated, guard tampers re-executed
with byte-identical restore, full smoke re-run green.
…e manifest

Addresses all three findings from the review of head a2f580d (see
req/plans/phase8-2-reference-findings.md section 13.4 item 13 for the
contract changes and evidence):

- repo_dir is now required to resolve to the repository the worker runs
  from (anchored on the worker file, like manifest_path), so the
  report's code identity can never name a different checkout
- fixed-continuation full_generation_s is the step-region wall time
  (prefill excluded) in both adapters, and the stock prompt cache is
  created before the timer starts; prefill_s is documented per-adapter
  and not cross-adapter comparable (the stock shipped call projects the
  output head at every prompt position, the controlled one only at the
  last)
- report writing: per-process temp name; a success whose report link is
  refused is downgraded to failed (exit 1); a temp-write failure removes
  the temp file before propagating

Manifest re-pinned (digest 1c691642...), guard backstop updated, guard
tampers re-executed with byte-identical restore, worker negative suites
(13 + 14 assertions) and the T2 timeout probe (in-loop 22-record break
and post-loop 26-record timeout on the final head) re-run green, full
smoke re-run green.
…e manifest

Addresses both findings from the review of head 602016e (see
req/plans/phase8-2-reference-findings.md section 13.4 item 14 for the
contract changes and evidence):

- the worker claims the launcher-created output directory with an
  exclusive-create worker-claim file ({utc, pid}) before any validation;
  on an existing claim it writes nothing into the directory, reports the
  refusal on stderr and exits 1 (a second worker -- valid or not -- can
  no longer take over a folder owned by another worker; the claim is
  sticky: a claimed folder without a report is dead for the launcher)
- the report's worker block carries the worker's pid, worker-ready is
  created with exclusive create, and the report-link refusal message is
  built from the final state and printed after the success downgrade, so
  it never reports an ok status the run was downgraded away from
- fixed-continuation's full_generation_s is renamed step_region_s (with
  full_generation_s null in that mode, and step_region_s null in the
  other modes -- uniform key set, one value per mode), since the step
  region includes the per-step full-vocabulary sort/argmax host work
  (about 39-46% of the 0.6b L=512 value), not decode time; decode
  comparisons use the per-step s values

Manifest re-pinned (digest 10eaaa0c...), guard backstop updated, guard
tampers re-executed with byte-identical restore, worker negative suites
(13 + 14 assertions), the new round-3 suite (26 assertions, including
the reviewer's repro), the T2 timeout probe (11/11; the probe's aim
itself was fixed -- the deadline sits at ooff + T, not T, because the
worker sets it before the mlx_lm import/load, and the post-loop branch
is now exercised on the reference-pair single-request cell) and the full
smoke re-run (30 cross-checks) all green.
…e manifest

Addresses both findings from the review of head 94fea72 (see
req/plans/phase8-2-reference-findings.md section 13.4 item 15 for the
contract changes and evidence):

- an out_dir that is not an existing directory is now rejected before the
  claim create with the old message ("worker validation failed: out_dir
  must be created by the launcher", exit 1, nothing written), instead of
  surfacing as a raw FileNotFoundError from the claim; the now-dead
  out_dir check in validate is removed, and the module docstring's claim
  bullet records the rejection
- the two comments still calling the step region "the comparable
  quantity" (controlled.py and the stock fixed-continuation branch in
  worker.py) are reworded to agree with the updated section 13.3 (the
  step region is not decode time; decode comparisons use the per-step s
  values), and the controlled.py docstring/local variable are renamed
  full_s to step_region_s to match the report field (the controlled.py
  rewording rides this round's re-pin)

Manifest re-pinned (digest e8c77301...), guard backstop updated, guard
tampers re-executed with byte-identical restore, the worker negative
suites (20 + 14 assertions, including new T8 missing-dir and T9
file-in-the-way tests), the round-3 suite (26 assertions) and the T2
timeout probe (11/11: in-loop break at T=8 s on the first attempt under
the corrected T-space aim, post-loop check at T=1 s on the
reference-pair cell) and the full smoke re-run (30 cross-checks) all
green.
@perNyfelt
perNyfelt merged commit 36a9250 into main Nov 7, 2026
3 checks passed
@perNyfelt
perNyfelt deleted the phase8-2-wp1 branch November 7, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant